Compare commits
10 Commits
9b5eea54ca
...
9143f73317
| Author | SHA1 | Date | |
|---|---|---|---|
| 9143f73317 | |||
| 3012599263 | |||
| e53deb57bb | |||
| 50c28932e3 | |||
| 2df606cbdd | |||
| cca3469c1f | |||
| 7e399fe544 | |||
| cf4fb922f0 | |||
| fae8594bc1 | |||
| 4abca5928c |
@@ -26,7 +26,25 @@
|
|||||||
"Bash(diesel migration run:*)",
|
"Bash(diesel migration run:*)",
|
||||||
"Bash(sqlite3:*)",
|
"Bash(sqlite3:*)",
|
||||||
"Bash(diesel migration redo:*)",
|
"Bash(diesel migration redo:*)",
|
||||||
"Bash(tree:*)"
|
"Bash(tree:*)",
|
||||||
|
"Bash(timeout 180 cargo build:*)",
|
||||||
|
"Bash(timeout 5 cargo run:*)",
|
||||||
|
"Bash(DATABASE_URL=\"../cursebreaker.db\" timeout 10 cargo run:*)",
|
||||||
|
"Bash(DATABASE_URL=\"../cursebreaker.db\" timeout -s TERM 3 cargo run:*)",
|
||||||
|
"Bash(curl:*)",
|
||||||
|
"Bash(diesel print-schema:*)",
|
||||||
|
"Bash(time cargo run:*)",
|
||||||
|
"Bash(DATABASE_URL=../cursebreaker.db diesel migration:*)",
|
||||||
|
"Bash(DATABASE_URL=cursebreaker.db diesel migration:*)",
|
||||||
|
"Bash(DATABASE_URL=../cursebreaker-parser/cursebreaker.db cargo run:*)",
|
||||||
|
"Bash(identify:*)",
|
||||||
|
"Bash(diesel migration revert:*)",
|
||||||
|
"Bash(xargs:*)",
|
||||||
|
"Bash(ss:*)",
|
||||||
|
"Bash(timeout 10 cargo run:*)",
|
||||||
|
"Bash(timeout 60 cargo run:*)",
|
||||||
|
"Bash(DATABASE_URL=../cursebreaker.db diesel print-schema:*)",
|
||||||
|
"Bash(DATABASE_URL=../cursebreaker.db diesel database:*)"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/home/connor/repos/CBAssets/"
|
"/home/connor/repos/CBAssets/"
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,3 +19,4 @@ target/
|
|||||||
|
|
||||||
# Test data (cloned Unity projects for integration tests)
|
# Test data (cloned Unity projects for integration tests)
|
||||||
test_data/
|
test_data/
|
||||||
|
cursebreaker.db
|
||||||
|
|||||||
797
Cargo.lock
generated
797
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["unity-parser", "unity-parser-macros", "cursebreaker-parser"]
|
members = ["unity-parser", "unity-parser-macros", "cursebreaker-parser", "cursebreaker-map"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
12
cursebreaker-map/.gitignore
vendored
Normal file
12
cursebreaker-map/.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Rust
|
||||||
|
/target/
|
||||||
|
Cargo.lock
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
20
cursebreaker-map/Cargo.toml
Normal file
20
cursebreaker-map/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "cursebreaker-map"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = "0.7"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tower = "0.4"
|
||||||
|
tower-http = { version = "0.5", features = ["fs", "cors"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
diesel = { version = "2.1", features = ["sqlite", "returning_clauses_for_sqlite_3_35"] }
|
||||||
|
dotenvy = "0.15"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = "0.3"
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
|
[dependencies.cursebreaker-parser]
|
||||||
|
path = "../cursebreaker-parser"
|
||||||
126
cursebreaker-map/README.md
Normal file
126
cursebreaker-map/README.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# Cursebreaker Interactive Map
|
||||||
|
|
||||||
|
An interactive web-based map viewer for "The Black Grimoire: Cursebreaker" game, built with Rust (Axum) and Leaflet.js.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Optimized Tile Loading**: Uses merged tiles to reduce HTTP requests
|
||||||
|
- Zoom level 0: ~31 tiles (4×4 merged)
|
||||||
|
- Zoom level 1: ~105 tiles (2×2 merged)
|
||||||
|
- Zoom level 2: ~345 tiles (original tiles)
|
||||||
|
- **Lossless Compression**: All tiles use lossless WebP for optimal quality
|
||||||
|
- **High-Performance Rendering**: Serves tiles directly from SQLite database
|
||||||
|
- **Interactive Navigation**: Pan and zoom through the game world
|
||||||
|
- **Dark Theme UI**: Game-themed dark interface with collapsible sidebar
|
||||||
|
- **Real-time Coordinates**: Display tile and pixel coordinates while hovering
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Backend (Rust + Axum)
|
||||||
|
- **Tile Server**: Serves WebP-compressed map tiles from SQLite database
|
||||||
|
- **API Endpoints**:
|
||||||
|
- `GET /api/tiles/:z/:x/:y` - Retrieve tile at coordinates (x, y) and zoom level z
|
||||||
|
- `GET /api/bounds` - Get map bounds (min/max x/y coordinates)
|
||||||
|
- `GET /` - Serve static frontend files
|
||||||
|
|
||||||
|
### Frontend (Leaflet.js)
|
||||||
|
- **Image Overlay Layer**: Each merged tile is rendered as a positioned image overlay
|
||||||
|
- **Merged Tile System**: Reduces HTTP requests by merging tiles at lower zoom levels:
|
||||||
|
- Zoom 0: 4×4 original tiles merged into 512px images (~31 total requests)
|
||||||
|
- Zoom 1: 2×2 original tiles merged into 512px images (~105 total requests)
|
||||||
|
- Zoom 2: Original 512px tiles (1×1, ~345 total requests)
|
||||||
|
- **Fixed Coordinate System**: Uses Leaflet's CRS.Simple with tiles positioned at their exact pixel coordinates
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Rust (latest stable)
|
||||||
|
- SQLite database at `../cursebreaker.db` with `minimap_tiles` table populated
|
||||||
|
|
||||||
|
## Running the Map Viewer
|
||||||
|
|
||||||
|
### First Time Setup
|
||||||
|
|
||||||
|
1. **Generate all map tiles** (only needed once, or after updating minimap images):
|
||||||
|
```bash
|
||||||
|
cd cursebreaker-parser
|
||||||
|
cargo run --bin image-parser --release
|
||||||
|
```
|
||||||
|
This processes all PNG files and automatically generates all 3 zoom levels (takes ~1.5 minutes)
|
||||||
|
|
||||||
|
2. **Start the map server**:
|
||||||
|
```bash
|
||||||
|
cd ../cursebreaker-map
|
||||||
|
cargo run --release
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Open in browser**:
|
||||||
|
Navigate to `http://127.0.0.1:3000`
|
||||||
|
|
||||||
|
### Subsequent Runs
|
||||||
|
|
||||||
|
Just start the server (step 2 above). All tiles are stored in the database.
|
||||||
|
|
||||||
|
## Database Configuration
|
||||||
|
|
||||||
|
By default, the server looks for the database at `../cursebreaker.db`. You can override this with the `DATABASE_URL` environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=/path/to/cursebreaker.db cargo run --release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
The sidebar includes placeholders for upcoming features:
|
||||||
|
|
||||||
|
- **Icon Filtering**: Toggle visibility of shops, resources, fast travel points, workbenches, etc.
|
||||||
|
- **Map Markers**: Display game entities (shops, resources, NPCs) with clickable info popups
|
||||||
|
- **Search**: Find locations by name
|
||||||
|
- **Pathfinding**: Calculate routes between points
|
||||||
|
- **Layer Control**: Toggle different map overlays
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
cursebreaker-map/
|
||||||
|
├── Cargo.toml # Rust dependencies
|
||||||
|
├── src/
|
||||||
|
│ └── main.rs # Axum web server
|
||||||
|
├── static/
|
||||||
|
│ ├── index.html # Main HTML page
|
||||||
|
│ ├── style.css # Styling (dark theme)
|
||||||
|
│ └── map.js # Leaflet map initialization
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Notes
|
||||||
|
|
||||||
|
- **Merged Tiles**: Reduces HTTP requests by up to 91% at lowest zoom (31 vs 345 requests)
|
||||||
|
- **Lossless WebP**: High quality compression without artifacts
|
||||||
|
- **Database Storage**: All tiles served directly from SQLite BLOBs (no file I/O)
|
||||||
|
- **CRS.Simple**: Avoids expensive geographic coordinate projections
|
||||||
|
- **Total Storage**: ~111 MB for all zoom levels combined
|
||||||
|
|
||||||
|
### Load Performance Comparison
|
||||||
|
|
||||||
|
| Zoom Level | Merge Factor | Tiles Loaded | HTTP Requests Saved |
|
||||||
|
|------------|--------------|--------------|---------------------|
|
||||||
|
| 0 (zoomed out) | 4×4 | 31 | 91% fewer requests |
|
||||||
|
| 1 (medium) | 2×2 | 105 | 70% fewer requests |
|
||||||
|
| 2 (zoomed in) | 1×1 | 345 | baseline |
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Tiles not loading**:
|
||||||
|
- Verify database path is correct
|
||||||
|
- Check that `minimap_tiles` table is populated
|
||||||
|
- Look for errors in server console output
|
||||||
|
|
||||||
|
**Map appears blank**:
|
||||||
|
- Check browser console for JavaScript errors
|
||||||
|
- Verify `/api/bounds` returns valid coordinates
|
||||||
|
- Ensure tiles exist for the displayed coordinate range
|
||||||
|
|
||||||
|
**Performance issues**:
|
||||||
|
- Try running in release mode: `cargo run --release`
|
||||||
|
- Check database is on fast storage (SSD)
|
||||||
|
- Reduce browser zoom level to load lower-resolution tiles
|
||||||
234
cursebreaker-map/src/main.rs
Normal file
234
cursebreaker-map/src/main.rs
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::{header, StatusCode},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::get,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use base64::Engine;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tower_http::{cors::CorsLayer, services::ServeDir};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
// Database connection
|
||||||
|
type DbConnection = diesel::SqliteConnection;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct AppState {
|
||||||
|
database_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct MapBounds {
|
||||||
|
min_x: i32,
|
||||||
|
min_y: i32,
|
||||||
|
max_x: i32,
|
||||||
|
max_y: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ResourceResponse {
|
||||||
|
resources: Vec<ResourceGroup>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ResourceGroup {
|
||||||
|
item_id: i32,
|
||||||
|
name: String,
|
||||||
|
skill: String,
|
||||||
|
level: i32,
|
||||||
|
icon_base64: String,
|
||||||
|
positions: Vec<Position>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Position {
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Establish database connection
|
||||||
|
fn establish_connection(database_url: &str) -> Result<DbConnection, diesel::ConnectionError> {
|
||||||
|
SqliteConnection::establish(database_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get map bounds from database (using zoom level 2 tiles)
|
||||||
|
async fn get_bounds(State(state): State<Arc<AppState>>) -> Result<Json<MapBounds>, StatusCode> {
|
||||||
|
use cursebreaker_parser::schema::minimap_tiles::dsl::*;
|
||||||
|
use diesel::dsl::{max, min};
|
||||||
|
|
||||||
|
let mut conn = establish_connection(&state.database_url).map_err(|e| {
|
||||||
|
tracing::error!("Database connection error: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (min_x_val, max_x_val): (Option<i32>, Option<i32>) = minimap_tiles
|
||||||
|
.filter(zoom.eq(2)) // Only count zoom level 2 (original) tiles
|
||||||
|
.select((min(x), max(x)))
|
||||||
|
.first(&mut conn)
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Error querying min/max x: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (min_y_val, max_y_val): (Option<i32>, Option<i32>) = minimap_tiles
|
||||||
|
.filter(zoom.eq(2)) // Only count zoom level 2 (original) tiles
|
||||||
|
.select((min(y), max(y)))
|
||||||
|
.first(&mut conn)
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Error querying min/max y: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(MapBounds {
|
||||||
|
min_x: min_x_val.unwrap_or(0),
|
||||||
|
min_y: min_y_val.unwrap_or(0),
|
||||||
|
max_x: max_x_val.unwrap_or(0),
|
||||||
|
max_y: max_y_val.unwrap_or(0),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tile by coordinates and zoom level
|
||||||
|
async fn get_tile(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path((z, tile_x, tile_y)): Path<(i32, i32, i32)>,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
use cursebreaker_parser::schema::minimap_tiles::dsl::*;
|
||||||
|
|
||||||
|
let mut conn = establish_connection(&state.database_url).map_err(|e| {
|
||||||
|
tracing::error!("Database connection error: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Query minimap_tiles table for the tile at the requested zoom level
|
||||||
|
let tile_data = minimap_tiles
|
||||||
|
.filter(zoom.eq(z))
|
||||||
|
.filter(x.eq(tile_x))
|
||||||
|
.filter(y.eq(tile_y))
|
||||||
|
.select(image)
|
||||||
|
.first::<Vec<u8>>(&mut conn)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Error querying tile: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match tile_data {
|
||||||
|
Some(data) => {
|
||||||
|
info!(
|
||||||
|
"Serving tile at ({}, {}) zoom {} - {} bytes",
|
||||||
|
tile_x,
|
||||||
|
tile_y,
|
||||||
|
z,
|
||||||
|
data.len()
|
||||||
|
);
|
||||||
|
Ok(([(header::CONTENT_TYPE, "image/webp")], data).into_response())
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::warn!("Tile not found: ({}, {}) at zoom {}", tile_x, tile_y, z);
|
||||||
|
Err(StatusCode::NOT_FOUND)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all resources with icons from database
|
||||||
|
async fn get_resources(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> Result<Json<ResourceResponse>, StatusCode> {
|
||||||
|
use cursebreaker_parser::schema::{harvestables, resource_icons, world_resources};
|
||||||
|
|
||||||
|
let mut conn = establish_connection(&state.database_url).map_err(|e| {
|
||||||
|
tracing::error!("Database connection error: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Query with three-way join
|
||||||
|
let results = world_resources::table
|
||||||
|
.inner_join(
|
||||||
|
resource_icons::table.on(world_resources::item_id.eq(resource_icons::item_id)),
|
||||||
|
)
|
||||||
|
.inner_join(harvestables::table.on(resource_icons::item_id.eq(harvestables::id)))
|
||||||
|
.select((
|
||||||
|
resource_icons::item_id,
|
||||||
|
resource_icons::name,
|
||||||
|
harvestables::skill,
|
||||||
|
harvestables::level,
|
||||||
|
resource_icons::icon_64,
|
||||||
|
world_resources::pos_x,
|
||||||
|
world_resources::pos_y,
|
||||||
|
))
|
||||||
|
.order_by((harvestables::skill, harvestables::level))
|
||||||
|
.load::<(i32, String, String, i32, Vec<u8>, f32, f32)>(&mut conn)
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Error querying resources: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Group results by item_id
|
||||||
|
use std::collections::HashMap;
|
||||||
|
let mut grouped: HashMap<i32, ResourceGroup> = HashMap::new();
|
||||||
|
|
||||||
|
for (item_id, name, skill, level, icon_bytes, pos_x, pos_y) in results {
|
||||||
|
let entry = grouped.entry(item_id).or_insert_with(|| {
|
||||||
|
// Convert icon to base64 (only once per resource type)
|
||||||
|
let icon_base64 = base64::engine::general_purpose::STANDARD.encode(&icon_bytes);
|
||||||
|
|
||||||
|
ResourceGroup {
|
||||||
|
item_id,
|
||||||
|
name,
|
||||||
|
skill,
|
||||||
|
level,
|
||||||
|
icon_base64,
|
||||||
|
positions: Vec::new(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add position with multiplier applied
|
||||||
|
entry.positions.push(Position {
|
||||||
|
x: pos_x * 5.12,
|
||||||
|
y: pos_y * 5.12,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to vec and sort by skill and level
|
||||||
|
let mut resources: Vec<ResourceGroup> = grouped.into_values().collect();
|
||||||
|
resources.sort_by(|a, b| a.skill.cmp(&b.skill).then(a.level.cmp(&b.level)));
|
||||||
|
|
||||||
|
info!("Returning {} resource types", resources.len());
|
||||||
|
|
||||||
|
Ok(Json(ResourceResponse { resources }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
// Initialize tracing
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
|
// Get database path
|
||||||
|
let database_url = std::env::var("DATABASE_URL")
|
||||||
|
.unwrap_or_else(|_| "../cursebreaker.db".to_string());
|
||||||
|
|
||||||
|
info!("Using database: {}", database_url);
|
||||||
|
|
||||||
|
let state = Arc::new(AppState { database_url });
|
||||||
|
|
||||||
|
// Build router
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/api/bounds", get(get_bounds))
|
||||||
|
.route("/api/tiles/:z/:x/:y", get(get_tile))
|
||||||
|
.route("/api/resources", get(get_resources))
|
||||||
|
.nest_service("/", ServeDir::new("static"))
|
||||||
|
.layer(CorsLayer::permissive())
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
info!("Server running on http://127.0.0.1:3000");
|
||||||
|
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
}
|
||||||
44
cursebreaker-map/static/config.js
Normal file
44
cursebreaker-map/static/config.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// Map Configuration
|
||||||
|
// You can adjust these values and reload the page to test different zoom behaviors
|
||||||
|
|
||||||
|
const MapConfig = {
|
||||||
|
// Zoom level configuration
|
||||||
|
// Maps Leaflet zoom levels to database zoom levels and merge factors
|
||||||
|
zoomLevels: [
|
||||||
|
// Leaflet zoom 0 → Database zoom 0 (4x4 merged)
|
||||||
|
{ leafletZoom: -2, dbZoom: 0, mergeFactor: 4, label: "4x4 merged" },
|
||||||
|
// Leaflet zoom 1 → Database zoom 1 (2x2 merged)
|
||||||
|
{ leafletZoom: -0.5, dbZoom: 1, mergeFactor: 2, label: "2x2 merged" },
|
||||||
|
// Leaflet zoom 2+ → Database zoom 2 (original tiles)
|
||||||
|
{ leafletZoom: 1, dbZoom: 2, mergeFactor: 1, label: "original" },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Leaflet map settings
|
||||||
|
minZoom: -2,
|
||||||
|
maxZoom: 2,
|
||||||
|
|
||||||
|
// Tile size (in pixels) - should match database tile size
|
||||||
|
tileSize: 512,
|
||||||
|
|
||||||
|
// Debug mode - shows tile boundaries and coordinates
|
||||||
|
debug: true,
|
||||||
|
|
||||||
|
// Resource icon configuration
|
||||||
|
resourceIconSize: 48, // Icon size in pixels (configurable)
|
||||||
|
|
||||||
|
// Get zoom configuration for a specific Leaflet zoom level
|
||||||
|
getZoomConfig(leafletZoom) {
|
||||||
|
// Find the appropriate config for this zoom level
|
||||||
|
// Use the highest matching config that's <= current zoom
|
||||||
|
let config = this.zoomLevels[0];
|
||||||
|
for (const zoomConfig of this.zoomLevels) {
|
||||||
|
if (leafletZoom >= zoomConfig.leafletZoom) {
|
||||||
|
config = zoomConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make it globally available
|
||||||
|
window.MapConfig = MapConfig;
|
||||||
63
cursebreaker-map/static/index.html
Normal file
63
cursebreaker-map/static/index.html
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Cursebreaker Interactive Map</title>
|
||||||
|
|
||||||
|
<!-- Leaflet CSS -->
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
|
||||||
|
<!-- Custom CSS -->
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div id="sidebar" class="sidebar collapsed">
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<h2>Cursebreaker Map</h2>
|
||||||
|
<div class="info-section">
|
||||||
|
<p class="subtitle">The Black Grimoire: Cursebreaker</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filters-section">
|
||||||
|
<h3>Resources</h3>
|
||||||
|
<div class="filter-controls">
|
||||||
|
<button id="select-all-resources" class="filter-btn">Show All</button>
|
||||||
|
<button id="deselect-all-resources" class="filter-btn">Hide All</button>
|
||||||
|
</div>
|
||||||
|
<div id="resource-filters" class="filter-group">
|
||||||
|
<p class="loading-text">Loading resources...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="map-info">
|
||||||
|
<h3>Map Info</h3>
|
||||||
|
<div id="map-stats">
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map Container -->
|
||||||
|
<div id="map"></div>
|
||||||
|
|
||||||
|
<!-- Coordinates Display -->
|
||||||
|
<div id="coordinates" class="coordinates-display">
|
||||||
|
Coordinates: <span id="coord-text">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Leaflet JS -->
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
|
||||||
|
<!-- Configuration (edit this to adjust zoom levels) -->
|
||||||
|
<script src="config.js"></script>
|
||||||
|
|
||||||
|
<!-- Custom JS -->
|
||||||
|
<script src="map.js"></script>
|
||||||
|
<script src="resources.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
270
cursebreaker-map/static/map.js
Normal file
270
cursebreaker-map/static/map.js
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
// Initialize the map when the page loads
|
||||||
|
let map;
|
||||||
|
let bounds;
|
||||||
|
let tileLayerGroup;
|
||||||
|
let debugLayerGroup;
|
||||||
|
|
||||||
|
async function initMap() {
|
||||||
|
try {
|
||||||
|
// Fetch map bounds from the API
|
||||||
|
const response = await fetch('/api/bounds');
|
||||||
|
bounds = await response.json();
|
||||||
|
|
||||||
|
console.log('Map bounds:', bounds);
|
||||||
|
|
||||||
|
// Update sidebar with map info
|
||||||
|
updateMapInfo(bounds);
|
||||||
|
|
||||||
|
// Calculate map dimensions in tiles
|
||||||
|
const width = bounds.max_x - bounds.min_x + 1;
|
||||||
|
const height = bounds.max_y - bounds.min_y + 1;
|
||||||
|
|
||||||
|
// Get config
|
||||||
|
const config = window.MapConfig;
|
||||||
|
const tileSize = config.tileSize;
|
||||||
|
|
||||||
|
// Create map with simple CRS (not geographic)
|
||||||
|
map = L.map('map', {
|
||||||
|
crs: L.CRS.Simple,
|
||||||
|
minZoom: config.minZoom,
|
||||||
|
maxZoom: config.maxZoom,
|
||||||
|
attributionControl: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate bounds for Leaflet (in pixels)
|
||||||
|
// Origin at top-left [0,0], y increases down, x increases right
|
||||||
|
const pixelWidth = width * tileSize;
|
||||||
|
const pixelHeight = height * tileSize;
|
||||||
|
|
||||||
|
const mapBounds = [
|
||||||
|
[0, 0],
|
||||||
|
[pixelHeight, pixelWidth]
|
||||||
|
];
|
||||||
|
|
||||||
|
// Set max bounds to prevent panning outside the map
|
||||||
|
map.setMaxBounds(mapBounds);
|
||||||
|
|
||||||
|
// Fit the map to bounds
|
||||||
|
map.fitBounds(mapBounds);
|
||||||
|
|
||||||
|
// Create layer groups
|
||||||
|
tileLayerGroup = L.layerGroup().addTo(map);
|
||||||
|
|
||||||
|
if (config.debug) {
|
||||||
|
debugLayerGroup = L.layerGroup().addTo(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load tiles for current zoom
|
||||||
|
loadTilesForCurrentZoom();
|
||||||
|
|
||||||
|
// Reload tiles when zoom changes
|
||||||
|
map.on('zoomend', function() {
|
||||||
|
loadTilesForCurrentZoom();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add coordinate display on mouse move
|
||||||
|
map.on('mousemove', function(e) {
|
||||||
|
const lat = e.latlng.lat;
|
||||||
|
const lng = e.latlng.lng;
|
||||||
|
|
||||||
|
// Convert pixel coordinates to tile coordinates
|
||||||
|
const tileX = Math.floor(lng / tileSize);
|
||||||
|
const tileY = Math.floor(lat / tileSize);
|
||||||
|
|
||||||
|
const leafletZoom = map.getZoom();
|
||||||
|
const zoomConfig = config.getZoomConfig(leafletZoom);
|
||||||
|
|
||||||
|
// Calculate which merged tile this is in
|
||||||
|
const mergedTileX = Math.floor(tileX / zoomConfig.mergeFactor);
|
||||||
|
const mergedTileY = Math.floor(tileY / zoomConfig.mergeFactor);
|
||||||
|
|
||||||
|
document.getElementById('coord-text').textContent =
|
||||||
|
`Tile (${tileX}, ${tileY}) | Merged (${mergedTileX}, ${mergedTileY}) | Zoom ${leafletZoom} (DB ${zoomConfig.dbZoom})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add attribution
|
||||||
|
L.control.attribution({
|
||||||
|
position: 'bottomright',
|
||||||
|
prefix: false
|
||||||
|
}).addAttribution('The Black Grimoire: Cursebreaker').addTo(map);
|
||||||
|
|
||||||
|
// Add sidebar toggle control
|
||||||
|
const SidebarControl = L.Control.extend({
|
||||||
|
options: {
|
||||||
|
position: 'topleft'
|
||||||
|
},
|
||||||
|
|
||||||
|
onAdd: function(map) {
|
||||||
|
const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control');
|
||||||
|
const button = L.DomUtil.create('a', 'leaflet-control-sidebar', container);
|
||||||
|
button.innerHTML = '☰';
|
||||||
|
button.href = '#';
|
||||||
|
button.title = 'Toggle Sidebar';
|
||||||
|
|
||||||
|
L.DomEvent.on(button, 'click', function(e) {
|
||||||
|
L.DomEvent.preventDefault(e);
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
sidebar.classList.toggle('collapsed');
|
||||||
|
});
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addControl(new SidebarControl());
|
||||||
|
|
||||||
|
console.log('Map initialized successfully');
|
||||||
|
|
||||||
|
// Load resources asynchronously
|
||||||
|
loadResources().catch(error => {
|
||||||
|
console.error('Failed to load resources:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing map:', error);
|
||||||
|
document.getElementById('map-stats').innerHTML =
|
||||||
|
'<p style="color: #ff6b6b;">Error loading map data</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTilesForCurrentZoom() {
|
||||||
|
// Clear existing tiles
|
||||||
|
tileLayerGroup.clearLayers();
|
||||||
|
if (debugLayerGroup) {
|
||||||
|
debugLayerGroup.clearLayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentZoom = map.getZoom();
|
||||||
|
const config = window.MapConfig;
|
||||||
|
const tileSize = config.tileSize;
|
||||||
|
|
||||||
|
// Get zoom configuration
|
||||||
|
const zoomConfig = config.getZoomConfig(currentZoom);
|
||||||
|
const dbZoom = zoomConfig.dbZoom;
|
||||||
|
const mergeFactor = zoomConfig.mergeFactor;
|
||||||
|
|
||||||
|
console.log(`\n=== Loading tiles at Leaflet zoom ${currentZoom} ===`);
|
||||||
|
console.log(`Database zoom: ${dbZoom}, Merge factor: ${mergeFactor} (${zoomConfig.label})`);
|
||||||
|
console.log(`Bounds: X [${bounds.min_x}, ${bounds.max_x}], Y [${bounds.min_y}, ${bounds.max_y}]`);
|
||||||
|
|
||||||
|
// Calculate which merged tiles we need to load
|
||||||
|
// The database stores merged tile coordinates starting from 0
|
||||||
|
// For a 2x2 merge of tiles (0,0), (0,1), (1,0), (1,1), the database stores it at (0,0)
|
||||||
|
// For original tiles at min_x=0, with mergeFactor=2, we need tiles starting at x=0/2=0
|
||||||
|
|
||||||
|
const minMergedX = Math.floor(bounds.min_x / mergeFactor);
|
||||||
|
const maxMergedX = Math.floor(bounds.max_x / mergeFactor);
|
||||||
|
const minMergedY = Math.floor(bounds.min_y / mergeFactor);
|
||||||
|
const maxMergedY = Math.floor(bounds.max_y / mergeFactor);
|
||||||
|
|
||||||
|
console.log(`Merged tile range: X [${minMergedX}, ${maxMergedX}], Y [${minMergedY}, ${maxMergedY}]`);
|
||||||
|
|
||||||
|
let tileCount = 0;
|
||||||
|
let loadedCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
// Load each merged tile
|
||||||
|
for (let mergedY = minMergedY; mergedY <= maxMergedY; mergedY++) {
|
||||||
|
for (let mergedX = minMergedX; mergedX <= maxMergedX; mergedX++) {
|
||||||
|
// Calculate the pixel bounds for this merged tile
|
||||||
|
// The merged tile at (mergedX, mergedY) covers original tiles starting at:
|
||||||
|
// (mergedX * mergeFactor, mergedY * mergeFactor)
|
||||||
|
const startTileX = mergedX * mergeFactor;
|
||||||
|
const startTileY = mergedY * mergeFactor;
|
||||||
|
|
||||||
|
const pixelMinX = startTileX * tileSize;
|
||||||
|
const pixelMinY = startTileY * tileSize;
|
||||||
|
const pixelMaxX = (startTileX + mergeFactor) * tileSize;
|
||||||
|
const pixelMaxY = (startTileY + mergeFactor) * tileSize;
|
||||||
|
|
||||||
|
const tileBounds = [
|
||||||
|
[pixelMinY, pixelMinX],
|
||||||
|
[pixelMaxY, pixelMaxX]
|
||||||
|
];
|
||||||
|
|
||||||
|
// Request the merged tile from the API
|
||||||
|
const imageUrl = `/api/tiles/${dbZoom}/${mergedX}/${mergedY}`;
|
||||||
|
|
||||||
|
if (config.debug && tileCount < 5) {
|
||||||
|
console.log(` Tile ${tileCount}: DB(${mergedX},${mergedY}) → Pixels [${pixelMinX},${pixelMinY}] to [${pixelMaxX},${pixelMaxY}]`);
|
||||||
|
console.log(` URL: ${imageUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlay = L.imageOverlay(imageUrl, tileBounds, {
|
||||||
|
opacity: 1,
|
||||||
|
errorOverlayUrl: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.on('load', function() {
|
||||||
|
loadedCount++;
|
||||||
|
if (config.debug && loadedCount <= 3) {
|
||||||
|
console.log(` ✓ Loaded tile (${mergedX}, ${mergedY})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.on('error', function() {
|
||||||
|
errorCount++;
|
||||||
|
console.warn(` ✗ Failed to load tile (${mergedX}, ${mergedY}) from ${imageUrl}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.addTo(tileLayerGroup);
|
||||||
|
tileCount++;
|
||||||
|
|
||||||
|
// Add debug overlay if enabled
|
||||||
|
if (config.debug && debugLayerGroup) {
|
||||||
|
// Draw rectangle showing tile boundaries
|
||||||
|
const rect = L.rectangle(tileBounds, {
|
||||||
|
color: '#ff0000',
|
||||||
|
weight: 1,
|
||||||
|
fillOpacity: 0,
|
||||||
|
interactive: false
|
||||||
|
}).addTo(debugLayerGroup);
|
||||||
|
|
||||||
|
// Add label showing tile coordinates
|
||||||
|
const center = [
|
||||||
|
(pixelMinY + pixelMaxY) / 2,
|
||||||
|
(pixelMinX + pixelMaxX) / 2
|
||||||
|
];
|
||||||
|
|
||||||
|
const label = L.marker(center, {
|
||||||
|
icon: L.divIcon({
|
||||||
|
className: 'tile-label',
|
||||||
|
html: `<div style="background: rgba(0,0,0,0.7); color: #fff; padding: 2px 5px; border-radius: 3px; font-size: 11px; white-space: nowrap;">
|
||||||
|
DB: (${mergedX},${mergedY})<br/>
|
||||||
|
Z: ${dbZoom}
|
||||||
|
</div>`,
|
||||||
|
iconSize: [60, 30],
|
||||||
|
iconAnchor: [30, 15]
|
||||||
|
}),
|
||||||
|
interactive: false
|
||||||
|
}).addTo(debugLayerGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Requested ${tileCount} tiles (merge factor ${mergeFactor}x${mergeFactor})`);
|
||||||
|
|
||||||
|
// Wait a bit and report results
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log(`Results: ${loadedCount} loaded, ${errorCount} errors, ${tileCount - loadedCount - errorCount} pending`);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMapInfo(bounds) {
|
||||||
|
const width = bounds.max_x - bounds.min_x + 1;
|
||||||
|
const height = bounds.max_y - bounds.min_y + 1;
|
||||||
|
const config = window.MapConfig;
|
||||||
|
|
||||||
|
document.getElementById('map-stats').innerHTML = `
|
||||||
|
<p><strong>Bounds:</strong></p>
|
||||||
|
<p>X: ${bounds.min_x} to ${bounds.max_x}</p>
|
||||||
|
<p>Y: ${bounds.min_y} to ${bounds.max_y}</p>
|
||||||
|
<p><strong>Size:</strong> ${width} × ${height} tiles</p>
|
||||||
|
<p><strong>Zoom levels:</strong> ${config.minZoom}-${config.maxZoom}</p>
|
||||||
|
<p><strong>Debug mode:</strong> ${config.debug ? 'ON' : 'OFF'}</p>
|
||||||
|
${config.debug ? '<p style="color: #8b5cf6; font-size: 12px;">Red boxes show tile boundaries</p>' : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize map when page loads
|
||||||
|
window.addEventListener('DOMContentLoaded', initMap);
|
||||||
261
cursebreaker-map/static/resources.js
Normal file
261
cursebreaker-map/static/resources.js
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
// Resource management for Cursebreaker map
|
||||||
|
let resourceLayerGroups = {}; // Map: resource name -> L.layerGroup
|
||||||
|
let resourceIcons = {}; // Map: resource name -> L.icon
|
||||||
|
let resourceData = {}; // Map: resource name -> resource metadata (skill, level, etc.)
|
||||||
|
let filterState = {}; // Map: resource name -> boolean (visible)
|
||||||
|
|
||||||
|
// Load resources from API
|
||||||
|
async function loadResources() {
|
||||||
|
try {
|
||||||
|
console.log('Loading resources from API...');
|
||||||
|
const response = await fetch('/api/resources');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log(`Received ${data.resources.length} resource types`);
|
||||||
|
|
||||||
|
// Create icons and layer groups for each resource
|
||||||
|
for (const group of data.resources) {
|
||||||
|
createResourceGroup(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize filter UI
|
||||||
|
initializeFilterUI();
|
||||||
|
|
||||||
|
// Restore filter state from localStorage
|
||||||
|
restoreFilterState();
|
||||||
|
|
||||||
|
console.log(`Loaded ${data.resources.length} resource types successfully`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading resources:', error);
|
||||||
|
const container = document.getElementById('resource-filters');
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = '<p style="color: #ff6b6b;">Failed to load resources. Check console for details.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create resource group with icon and markers
|
||||||
|
function createResourceGroup(group) {
|
||||||
|
const config = window.MapConfig;
|
||||||
|
|
||||||
|
// Create icon definition (cached per resource type)
|
||||||
|
const iconUrl = `data:image/webp;base64,${group.icon_base64}`;
|
||||||
|
const icon = L.icon({
|
||||||
|
iconUrl: iconUrl,
|
||||||
|
iconSize: [config.resourceIconSize, config.resourceIconSize],
|
||||||
|
iconAnchor: [config.resourceIconSize / 2, config.resourceIconSize / 2],
|
||||||
|
popupAnchor: [0, -(config.resourceIconSize / 2)],
|
||||||
|
});
|
||||||
|
|
||||||
|
resourceIcons[group.name] = icon;
|
||||||
|
|
||||||
|
// Store metadata
|
||||||
|
resourceData[group.name] = {
|
||||||
|
item_id: group.item_id,
|
||||||
|
skill: group.skill,
|
||||||
|
level: group.level,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create layer group for this resource type
|
||||||
|
const layerGroup = L.layerGroup();
|
||||||
|
|
||||||
|
// Add markers for all positions
|
||||||
|
for (const pos of group.positions) {
|
||||||
|
const marker = L.marker([pos.y, pos.x], {
|
||||||
|
icon: icon,
|
||||||
|
title: group.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add popup with resource details
|
||||||
|
marker.bindPopup(
|
||||||
|
`<strong>${group.name}</strong><br/>Position: (${pos.x.toFixed(1)}, ${pos.y.toFixed(1)})`
|
||||||
|
);
|
||||||
|
|
||||||
|
marker.addTo(layerGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to map (initially visible)
|
||||||
|
layerGroup.addTo(map);
|
||||||
|
resourceLayerGroups[group.name] = layerGroup;
|
||||||
|
filterState[group.name] = true; // Initially visible
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize filter UI with skill grouping
|
||||||
|
function initializeFilterUI() {
|
||||||
|
const container = document.getElementById('resource-filters');
|
||||||
|
if (!container) {
|
||||||
|
console.error('resource-filters container not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = ''; // Clear loading text
|
||||||
|
|
||||||
|
// Group resources by skill
|
||||||
|
const skillGroups = {};
|
||||||
|
for (const name in resourceLayerGroups) {
|
||||||
|
const metadata = resourceData[name];
|
||||||
|
if (!skillGroups[metadata.skill]) {
|
||||||
|
skillGroups[metadata.skill] = [];
|
||||||
|
}
|
||||||
|
skillGroups[metadata.skill].push({
|
||||||
|
name: name,
|
||||||
|
level: metadata.level,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort skills alphabetically
|
||||||
|
const sortedSkills = Object.keys(skillGroups).sort();
|
||||||
|
|
||||||
|
// Create UI for each skill group
|
||||||
|
for (const skill of sortedSkills) {
|
||||||
|
const skillDiv = document.createElement('div');
|
||||||
|
skillDiv.className = 'skill-group';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'skill-header';
|
||||||
|
// Capitalize first letter of skill
|
||||||
|
header.textContent = skill.charAt(0).toUpperCase() + skill.slice(1);
|
||||||
|
skillDiv.appendChild(header);
|
||||||
|
|
||||||
|
// Resources are already sorted by level in backend, but sort again to be sure
|
||||||
|
skillGroups[skill].sort((a, b) => a.level - b.level);
|
||||||
|
|
||||||
|
// Create checkbox for each resource
|
||||||
|
for (const resource of skillGroups[skill]) {
|
||||||
|
const label = createFilterLabel(resource.name);
|
||||||
|
skillDiv.appendChild(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(skillDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach bulk filter handlers
|
||||||
|
const selectAllBtn = document.getElementById('select-all-resources');
|
||||||
|
const deselectAllBtn = document.getElementById('deselect-all-resources');
|
||||||
|
|
||||||
|
if (selectAllBtn) {
|
||||||
|
selectAllBtn.addEventListener('click', () => {
|
||||||
|
setAllFilters(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deselectAllBtn) {
|
||||||
|
deselectAllBtn.addEventListener('click', () => {
|
||||||
|
setAllFilters(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create filter label with checkbox and icon
|
||||||
|
function createFilterLabel(resourceName) {
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.className = 'filter-label';
|
||||||
|
|
||||||
|
const checkbox = document.createElement('input');
|
||||||
|
checkbox.type = 'checkbox';
|
||||||
|
checkbox.checked = filterState[resourceName];
|
||||||
|
checkbox.dataset.resource = resourceName;
|
||||||
|
checkbox.addEventListener('change', handleFilterChange);
|
||||||
|
|
||||||
|
const icon = document.createElement('img');
|
||||||
|
icon.src = resourceIcons[resourceName].options.iconUrl;
|
||||||
|
icon.className = 'filter-icon';
|
||||||
|
icon.alt = resourceName;
|
||||||
|
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.textContent = resourceName;
|
||||||
|
|
||||||
|
label.appendChild(checkbox);
|
||||||
|
label.appendChild(icon);
|
||||||
|
label.appendChild(text);
|
||||||
|
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle filter checkbox change
|
||||||
|
function handleFilterChange(event) {
|
||||||
|
const resourceName = event.target.dataset.resource;
|
||||||
|
const isVisible = event.target.checked;
|
||||||
|
|
||||||
|
filterState[resourceName] = isVisible;
|
||||||
|
|
||||||
|
// Show/hide layer group
|
||||||
|
const layerGroup = resourceLayerGroups[resourceName];
|
||||||
|
if (isVisible) {
|
||||||
|
layerGroup.addTo(map);
|
||||||
|
} else {
|
||||||
|
map.removeLayer(layerGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist state
|
||||||
|
saveFilterState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set all filters to visible or hidden
|
||||||
|
function setAllFilters(visible) {
|
||||||
|
for (const name in filterState) {
|
||||||
|
filterState[name] = visible;
|
||||||
|
const layerGroup = resourceLayerGroups[name];
|
||||||
|
|
||||||
|
if (visible) {
|
||||||
|
layerGroup.addTo(map);
|
||||||
|
} else {
|
||||||
|
map.removeLayer(layerGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update checkboxes
|
||||||
|
document.querySelectorAll('#resource-filters input[type="checkbox"]').forEach((cb) => {
|
||||||
|
cb.checked = visible;
|
||||||
|
});
|
||||||
|
|
||||||
|
saveFilterState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save filter state to localStorage
|
||||||
|
function saveFilterState() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('cursebreaker_resource_filters', JSON.stringify(filterState));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to save filter state to localStorage:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore filter state from localStorage
|
||||||
|
function restoreFilterState() {
|
||||||
|
const saved = localStorage.getItem('cursebreaker_resource_filters');
|
||||||
|
if (!saved) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedState = JSON.parse(saved);
|
||||||
|
|
||||||
|
for (const name in savedState) {
|
||||||
|
if (resourceLayerGroups[name]) {
|
||||||
|
filterState[name] = savedState[name];
|
||||||
|
|
||||||
|
const layerGroup = resourceLayerGroups[name];
|
||||||
|
if (!savedState[name]) {
|
||||||
|
map.removeLayer(layerGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update checkboxes after UI is created
|
||||||
|
setTimeout(() => {
|
||||||
|
document.querySelectorAll('#resource-filters input[type="checkbox"]').forEach((cb) => {
|
||||||
|
const name = cb.dataset.resource;
|
||||||
|
if (filterState[name] !== undefined) {
|
||||||
|
cb.checked = filterState[name];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
console.log('Restored filter state from localStorage');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to restore filter state:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
271
cursebreaker-map/static/style.css
Normal file
271
cursebreaker-map/static/style.css
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar {
|
||||||
|
width: 320px;
|
||||||
|
background: #2a2a2a;
|
||||||
|
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
transition: margin-left 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed {
|
||||||
|
margin-left: -320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar toggle control */
|
||||||
|
.leaflet-control-sidebar {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 30px;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar h2 {
|
||||||
|
color: #8b5cf6;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #a0a0a0;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section {
|
||||||
|
border-bottom: 1px solid #3a3a3a;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-section,
|
||||||
|
.map-info {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-section h3,
|
||||||
|
.map-info h3 {
|
||||||
|
color: #8b5cf6;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group label:hover {
|
||||||
|
background: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group input[type="checkbox"] {
|
||||||
|
margin-right: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon {
|
||||||
|
color: #a0a0a0;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map-stats {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #c0c0c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map-stats p {
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map */
|
||||||
|
#map {
|
||||||
|
flex: 1;
|
||||||
|
height: 100vh;
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Leaflet overrides for dark theme */
|
||||||
|
.leaflet-container {
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-control-zoom a {
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-control-zoom a:hover {
|
||||||
|
background: #3a3a3a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-bar {
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Coordinates display */
|
||||||
|
.coordinates-display {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(42, 42, 42, 0.95);
|
||||||
|
color: #e0e0e0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#coord-text {
|
||||||
|
color: #8b5cf6;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter controls */
|
||||||
|
.filter-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #3a3a3a;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
background: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:active {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter groups by skill */
|
||||||
|
.skill-group {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-header {
|
||||||
|
color: #8b5cf6;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: rgba(139, 92, 246, 0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter items */
|
||||||
|
.filter-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin-left: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label:hover {
|
||||||
|
background: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
image-rendering: -moz-crisp-edges;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
color: #a0a0a0;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme popups */
|
||||||
|
.leaflet-popup-content-wrapper {
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-popup-content {
|
||||||
|
margin: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed {
|
||||||
|
margin-left: -280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,10 +7,26 @@ edition = "2021"
|
|||||||
name = "cursebreaker_parser"
|
name = "cursebreaker_parser"
|
||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
# Main binary - runs all parsers
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "cursebreaker-parser"
|
name = "cursebreaker-parser"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
# XML Parser - loads game data from XML files and populates database
|
||||||
|
[[bin]]
|
||||||
|
name = "xml-parser"
|
||||||
|
path = "src/bin/xml-parser.rs"
|
||||||
|
|
||||||
|
# Scene Parser - parses Unity scenes and extracts game objects
|
||||||
|
[[bin]]
|
||||||
|
name = "scene-parser"
|
||||||
|
path = "src/bin/scene-parser.rs"
|
||||||
|
|
||||||
|
# Image Parser - processes minimap tiles and generates all zoom levels
|
||||||
|
[[bin]]
|
||||||
|
name = "image-parser"
|
||||||
|
path = "src/bin/image-parser.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
unity-parser = { path = "../unity-parser" }
|
unity-parser = { path = "../unity-parser" }
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
@@ -25,6 +41,7 @@ libsqlite3-sys = { version = ">=0.17.2", features = ["bundled"] }
|
|||||||
image = "0.25"
|
image = "0.25"
|
||||||
webp = "0.3"
|
webp = "0.3"
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
|
chrono = "0.4"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
diesel_migrations = "2.2"
|
diesel_migrations = "2.2"
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ A Rust library for parsing and managing game data from the Cursebreaker game. Th
|
|||||||
|
|
||||||
Cursebreaker Parser is designed to:
|
Cursebreaker Parser is designed to:
|
||||||
- Parse Unity scenes and extract game objects using the unity-parser library
|
- Parse Unity scenes and extract game objects using the unity-parser library
|
||||||
- Load game data from XML files (Items, NPCs, Quests, Harvestables, Loot tables)
|
- Load game data from XML files (Items, NPCs, Quests, Harvestables, Loot tables, Maps, Fast Travel, Player Houses, Traits, Shops)
|
||||||
|
- Process and compress minimap tiles and item images
|
||||||
- Provide in-memory databases for efficient querying of game data
|
- Provide in-memory databases for efficient querying of game data
|
||||||
- Serialize game data to SQL format for database storage
|
- Serialize game data to SQL format for database storage
|
||||||
|
|
||||||
@@ -17,8 +18,112 @@ Cursebreaker Parser is designed to:
|
|||||||
- **Quest Database**: Handle quest definitions, phases, and rewards
|
- **Quest Database**: Handle quest definitions, phases, and rewards
|
||||||
- **Harvestable Database**: Track harvestable resources and their drop tables
|
- **Harvestable Database**: Track harvestable resources and their drop tables
|
||||||
- **Loot Database**: Manage loot tables and drop configurations
|
- **Loot Database**: Manage loot tables and drop configurations
|
||||||
|
- **Map Database**: Handle map data and navigation
|
||||||
|
- **Fast Travel Database**: Manage fast travel locations and connections
|
||||||
|
- **Player House Database**: Track player houses and their locations
|
||||||
|
- **Trait Database**: Handle character traits and their effects
|
||||||
|
- **Shop Database**: Manage shop inventories and pricing
|
||||||
|
- **Minimap Database**: Process and manage minimap tiles with multiple zoom levels
|
||||||
- **XML Parsing**: Robust XML parsing with error handling
|
- **XML Parsing**: Robust XML parsing with error handling
|
||||||
- **SQL Export**: Prepare data for SQL database insertion
|
- **SQL Export**: Prepare data for SQL database insertion
|
||||||
|
- **Image Processing**: Process and compress minimap tiles and item icons
|
||||||
|
- **Unity Scene Parsing**: Extract game objects and world resources from Unity scenes
|
||||||
|
|
||||||
|
## Binaries
|
||||||
|
|
||||||
|
The project provides multiple binaries to handle different parsing tasks. This allows you to run only the parts you need, avoiding long load times for unnecessary operations.
|
||||||
|
|
||||||
|
### Available Binaries
|
||||||
|
|
||||||
|
1. **xml-parser** - Loads game data from XML files and populates the SQLite database
|
||||||
|
- Fast execution
|
||||||
|
- Run this when XML files change
|
||||||
|
```bash
|
||||||
|
cargo run --bin xml-parser
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **scene-parser** - Parses Unity scenes and extracts world objects
|
||||||
|
- Slow execution (Unity project initialization)
|
||||||
|
- Extracts multiple types of interactable components and their positions:
|
||||||
|
- **InteractableResource**: Harvestable resources → `world_resources` table
|
||||||
|
- **InteractableTeleporter**: Teleporters with source/destination positions → `world_teleporters` table
|
||||||
|
- **InteractableWorkbench**: Workbenches with workbench ID → `world_workbenches` table
|
||||||
|
- **LootSpawner**: Loot spawners with item, amount, respawn time → `world_loot` table
|
||||||
|
- **MapIcon**: Map icons with type, size, text, etc. → `world_map_icons` table
|
||||||
|
- **MapNameChanger**: Map name changers → `world_map_name_changers` table
|
||||||
|
- Processes item icons for harvestables:
|
||||||
|
- Looks up the first item drop for each harvestable from `harvestable_drops` table
|
||||||
|
- Loads the icon from `Data/Textures/ItemIcons/{item_id}.png`
|
||||||
|
- Applies white outline (4px) and resizes to 64x64
|
||||||
|
- Converts to WebP and stores in `resource_icons` table
|
||||||
|
- Run this when scene files change
|
||||||
|
```bash
|
||||||
|
cargo run --bin scene-parser
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **image-parser** - Processes minimap tiles
|
||||||
|
- Slow execution (image processing and compression)
|
||||||
|
- Run this when minimap images change
|
||||||
|
```bash
|
||||||
|
cargo run --bin image-parser
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **cursebreaker-parser** - All-in-one binary (runs all parsers)
|
||||||
|
- Slowest execution (runs everything)
|
||||||
|
- Use when you need to regenerate the entire database
|
||||||
|
```bash
|
||||||
|
cargo run --bin cursebreaker-parser
|
||||||
|
# or simply
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **verify-db** - Verifies database contents and shows basic statistics
|
||||||
|
```bash
|
||||||
|
cargo run --bin verify-db
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **verify-expanded-db** - Verifies expanded database schema with items, recipes, and stats
|
||||||
|
```bash
|
||||||
|
cargo run --bin verify-expanded-db
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **verify-images** - Verifies item images and shows storage statistics
|
||||||
|
```bash
|
||||||
|
cargo run --bin verify-images
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **verify-stats** - Verifies item stats and shows breakdown by type
|
||||||
|
```bash
|
||||||
|
cargo run --bin verify-stats
|
||||||
|
```
|
||||||
|
|
||||||
|
9. **verify-resource-icons** - Verifies resource icons for harvestables
|
||||||
|
```bash
|
||||||
|
cargo run --bin verify-resource-icons
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building for Production
|
||||||
|
|
||||||
|
Build specific binaries for release:
|
||||||
|
```bash
|
||||||
|
cargo build --release --bin xml-parser
|
||||||
|
cargo build --release --bin scene-parser
|
||||||
|
cargo build --release --bin image-parser
|
||||||
|
```
|
||||||
|
|
||||||
|
The compiled binaries will be in `target/release/`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Set the `CB_ASSETS_PATH` environment variable to the path of your CurseBreaker assets directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CB_ASSETS_PATH="/path/to/CBAssets"
|
||||||
|
```
|
||||||
|
|
||||||
|
If not set, the default fallback is `/home/connor/repos/CBAssets`.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -63,30 +168,197 @@ for (id, name, json) in sql_data.iter().take(5) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Querying World Resources
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use diesel::sqlite::SqliteConnection;
|
||||||
|
|
||||||
|
// Connect to database
|
||||||
|
let mut conn = SqliteConnection::establish("../cursebreaker.db")?;
|
||||||
|
|
||||||
|
// Define the structure
|
||||||
|
#[derive(Queryable, Debug)]
|
||||||
|
struct WorldResource {
|
||||||
|
item_id: i32,
|
||||||
|
pos_x: f32,
|
||||||
|
pos_y: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query resources by item ID
|
||||||
|
use cursebreaker_parser::schema::world_resources::dsl::*;
|
||||||
|
|
||||||
|
let copper_ore = world_resources
|
||||||
|
.filter(item_id.eq(2))
|
||||||
|
.load::<WorldResource>(&mut conn)?;
|
||||||
|
|
||||||
|
println!("Found {} copper ore nodes", copper_ore.len());
|
||||||
|
for resource in copper_ore {
|
||||||
|
println!(" Position: ({:.2}, {:.2})", resource.pos_x, resource.pos_y);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See `examples/query_world_resources.rs` for a complete example.
|
||||||
|
|
||||||
|
### Querying Resource Icons
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use diesel::sqlite::SqliteConnection;
|
||||||
|
|
||||||
|
// Connect to database
|
||||||
|
let mut conn = SqliteConnection::establish("../cursebreaker.db")?;
|
||||||
|
|
||||||
|
// Define the structure
|
||||||
|
#[derive(Queryable, Debug)]
|
||||||
|
struct ResourceIcon {
|
||||||
|
item_id: i32, // Harvestable ID
|
||||||
|
name: String, // Harvestable name
|
||||||
|
icon_64: Vec<u8>, // WebP image data (64x64 with white border)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query icon for a specific harvestable
|
||||||
|
use cursebreaker_parser::schema::resource_icons::dsl::*;
|
||||||
|
|
||||||
|
let copper_icon = resource_icons
|
||||||
|
.filter(item_id.eq(2)) // Harvestable ID for Copper Ore
|
||||||
|
.first::<ResourceIcon>(&mut conn)?;
|
||||||
|
|
||||||
|
println!("Found icon for: {}", copper_icon.name);
|
||||||
|
println!("Icon size: {} bytes (WebP format)", copper_icon.icon_64.len());
|
||||||
|
|
||||||
|
// Save to file if needed
|
||||||
|
std::fs::write("copper_ore.webp", &copper_icon.icon_64)?;
|
||||||
|
```
|
||||||
|
|
||||||
|
See `examples/resource_icons_example.rs` for a complete example.
|
||||||
|
|
||||||
|
### Additional Databases
|
||||||
|
|
||||||
|
Similar APIs are available for other game data types:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use cursebreaker_parser::{
|
||||||
|
MapDatabase, FastTravelDatabase, PlayerHouseDatabase,
|
||||||
|
TraitDatabase, ShopDatabase, MinimapDatabase
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load maps, fast travel points, player houses, etc.
|
||||||
|
let map_db = MapDatabase::load_from_xml("Data/XMLs/Maps/Map.xml")?;
|
||||||
|
// ... similar usage patterns
|
||||||
|
```
|
||||||
|
|
||||||
|
See the examples directory for usage of each database type.
|
||||||
|
|
||||||
|
### Database Verification
|
||||||
|
|
||||||
|
After parsing data, you can verify the database contents using the verification binaries:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic database verification
|
||||||
|
cargo run --bin verify-db
|
||||||
|
|
||||||
|
# Verify expanded schema with recipes and stats
|
||||||
|
cargo run --bin verify-expanded-db
|
||||||
|
|
||||||
|
# Check item images and storage usage
|
||||||
|
cargo run --bin verify-images
|
||||||
|
|
||||||
|
# Analyze item stats breakdown
|
||||||
|
cargo run --bin verify-stats
|
||||||
|
```
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
The project includes several example programs demonstrating different aspects of the parser:
|
||||||
|
|
||||||
|
- **game_data_demo.rs** - Comprehensive demo loading and querying all game data types (Items, NPCs, Quests, Harvestables, Loot)
|
||||||
|
- **item_database_demo.rs** - Focused on item database operations
|
||||||
|
- **query_world_resources.rs** - Querying world resource locations from the database
|
||||||
|
- **resource_icons_example.rs** - Querying processed harvestable icons with white borders
|
||||||
|
- **fast_travel_example.rs** - Working with fast travel locations
|
||||||
|
- **maps_example.rs** - Map data handling
|
||||||
|
- **player_houses_example.rs** - Player house management
|
||||||
|
- **shops_example.rs** - Shop inventory and pricing
|
||||||
|
- **traits_example.rs** - Character traits and effects
|
||||||
|
|
||||||
|
Run any example with:
|
||||||
|
```bash
|
||||||
|
cargo run --example <example_name>
|
||||||
|
```
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
cursebreaker-parser/
|
cursebreaker-parser/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── lib.rs # Library entry point and public API
|
│ ├── lib.rs # Library entry point and public API
|
||||||
│ ├── main.rs # Binary entry point
|
│ ├── main.rs # Main binary (all-in-one parser)
|
||||||
│ ├── xml_parser.rs # XML parsing utilities
|
│ ├── bin/ # Separate parser binaries
|
||||||
│ ├── item_loader.rs # Item loading logic
|
│ │ ├── xml-parser.rs # XML parsing only
|
||||||
│ ├── databases/ # Database implementations
|
│ │ ├── scene-parser.rs # Unity scene parsing only
|
||||||
|
│ │ ├── image-parser.rs # Image processing only
|
||||||
|
│ │ ├── verify-db.rs # Database verification
|
||||||
|
│ │ ├── verify-expanded-db.rs # Expanded database verification
|
||||||
|
│ │ ├── verify-images.rs # Image verification
|
||||||
|
│ │ ├── verify-stats.rs # Stats verification
|
||||||
|
│ │ └── verify-resource-icons.rs # Resource icons verification
|
||||||
|
│ ├── xml_parser.rs # XML parsing utilities
|
||||||
|
│ ├── image_processor.rs # Image processing utilities
|
||||||
|
│ ├── item_loader.rs # Item loading logic
|
||||||
|
│ ├── schema.rs # Database schema definitions
|
||||||
|
│ ├── databases/ # Database implementations
|
||||||
│ │ ├── item_database.rs
|
│ │ ├── item_database.rs
|
||||||
│ │ ├── npc_database.rs
|
│ │ ├── npc_database.rs
|
||||||
│ │ ├── quest_database.rs
|
│ │ ├── quest_database.rs
|
||||||
│ │ ├── harvestable_database.rs
|
│ │ ├── harvestable_database.rs
|
||||||
│ │ └── loot_database.rs
|
│ │ ├── loot_database.rs
|
||||||
│ └── types/ # Type definitions
|
│ │ ├── map_database.rs
|
||||||
│ ├── cursebreaker/ # Game-specific types (Items, NPCs, Quests, etc.)
|
│ │ ├── fast_travel_database.rs
|
||||||
│ └── monobehaviours/ # Unity MonoBehaviour types
|
│ │ ├── player_house_database.rs
|
||||||
├── examples/ # Example usage
|
│ │ ├── trait_database.rs
|
||||||
├── Cargo.toml # Package configuration
|
│ │ ├── shop_database.rs
|
||||||
└── XML_PARSING.md # XML parsing documentation
|
│ │ └── minimap_database.rs
|
||||||
|
│ └── types/ # Type definitions
|
||||||
|
│ ├── cursebreaker/ # Game-specific types (Items, NPCs, Quests, etc.)
|
||||||
|
│ └── monobehaviours/ # Unity MonoBehaviour types
|
||||||
|
├── examples/ # Example usage
|
||||||
|
│ ├── fast_travel_example.rs
|
||||||
|
│ ├── game_data_demo.rs
|
||||||
|
│ ├── item_database_demo.rs
|
||||||
|
│ ├── maps_example.rs
|
||||||
|
│ ├── player_houses_example.rs
|
||||||
|
│ ├── query_world_resources.rs
|
||||||
|
│ ├── shops_example.rs
|
||||||
|
│ └── traits_example.rs
|
||||||
|
├── migrations/ # Database migrations
|
||||||
|
├── Cargo.toml # Package configuration
|
||||||
|
├── XML_PARSING.md # XML parsing documentation
|
||||||
|
└── README.md # This file
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
The parser uses Diesel for database operations with SQLite. Database migrations are located in the `migrations/` directory and handle:
|
||||||
|
|
||||||
|
- Item data with stats, images, and crafting recipes
|
||||||
|
- NPC information and loot tables
|
||||||
|
- Quest definitions and phases
|
||||||
|
- Harvestable resources and drop tables
|
||||||
|
- World resource locations from Unity scenes
|
||||||
|
- Resource icons for harvestables (64x64 WebP with white borders)
|
||||||
|
- World teleporters with source/destination coordinates
|
||||||
|
- World workbenches with workbench IDs
|
||||||
|
- World loot spawners with item, amount, and respawn time
|
||||||
|
- Map icons with type, size, text, and hover text
|
||||||
|
- Map name changers with location and map name
|
||||||
|
- Minimap tiles and metadata
|
||||||
|
- Shop inventories and pricing
|
||||||
|
- Player houses and locations
|
||||||
|
- Fast travel points
|
||||||
|
- Character traits
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- **unity-parser**: For parsing Unity scene files
|
- **unity-parser**: For parsing Unity scene files
|
||||||
@@ -95,7 +367,8 @@ cursebreaker-parser/
|
|||||||
- **serde_json**: JSON support
|
- **serde_json**: JSON support
|
||||||
- **serde_yaml**: YAML support
|
- **serde_yaml**: YAML support
|
||||||
- **sparsey**: ECS (Entity Component System) support
|
- **sparsey**: ECS (Entity Component System) support
|
||||||
- **diesel**: Optional SQL database support
|
- **diesel**: SQL database support with SQLite
|
||||||
|
- **image**: Image processing and WebP compression
|
||||||
- **thiserror**: Error handling
|
- **thiserror**: Error handling
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
@@ -107,8 +380,18 @@ cargo build
|
|||||||
# Run tests
|
# Run tests
|
||||||
cargo test
|
cargo test
|
||||||
|
|
||||||
# Build with SQL support
|
# Build specific binaries
|
||||||
cargo build --features diesel
|
cargo build --bin xml-parser
|
||||||
|
cargo build --bin scene-parser
|
||||||
|
cargo build --bin image-parser
|
||||||
|
cargo build --bin verify-db
|
||||||
|
|
||||||
|
# Run examples
|
||||||
|
cargo run --example game_data_demo
|
||||||
|
cargo run --example item_database_demo
|
||||||
|
|
||||||
|
# Build for release
|
||||||
|
cargo build --release
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|||||||
@@ -1,442 +0,0 @@
|
|||||||
# XML Parsing in Cursebreaker Parser
|
|
||||||
|
|
||||||
This document describes the XML parsing functionality added to the cursebreaker-parser project.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The parser now supports loading game data from Cursebreaker's XML files and storing them in efficient data structures for runtime access and SQL database serialization.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- ✅ Parse Items, NPCs, Quests, and Harvestables XML files with full attribute and nested element support
|
|
||||||
- ✅ In-memory databases with fast lookups by ID, name, and various filters
|
|
||||||
- ✅ JSON serialization for SQL database storage
|
|
||||||
- ✅ Type-safe data structures with serde support
|
|
||||||
- ✅ Easy-to-use API with query methods
|
|
||||||
- ✅ Cross-referencing support between different data types
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Loading Items
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use cursebreaker_parser::ItemDatabase;
|
|
||||||
|
|
||||||
let item_db = ItemDatabase::load_from_xml("Data/XMLs/Items/Items.xml")?;
|
|
||||||
println!("Loaded {} items", item_db.len());
|
|
||||||
```
|
|
||||||
|
|
||||||
### Querying Items
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Get by ID
|
|
||||||
if let Some(item) = item_db.get_by_id(150) {
|
|
||||||
println!("Found: {}", item.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get by category
|
|
||||||
let bows = item_db.get_by_category("bow");
|
|
||||||
|
|
||||||
// Get by slot
|
|
||||||
let weapons = item_db.get_by_slot("weapon");
|
|
||||||
|
|
||||||
// Get by skill requirement
|
|
||||||
let magic_items = item_db.get_by_skill("magic");
|
|
||||||
|
|
||||||
// Get all items
|
|
||||||
for item in item_db.all_items() {
|
|
||||||
println!("{}: {}", item.id, item.name);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### SQL Serialization
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Prepare items for SQL insertion
|
|
||||||
let sql_data = item_db.prepare_for_sql();
|
|
||||||
|
|
||||||
for (id, name, json_data) in sql_data {
|
|
||||||
// INSERT INTO items (id, name, data) VALUES (?, ?, ?)
|
|
||||||
// Use your preferred SQL library to insert
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Structures
|
|
||||||
|
|
||||||
### Item
|
|
||||||
|
|
||||||
The main `Item` struct contains all item attributes from the XML:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct Item {
|
|
||||||
// Required
|
|
||||||
pub id: i32,
|
|
||||||
pub name: String,
|
|
||||||
|
|
||||||
// Optional attributes
|
|
||||||
pub level: Option<i32>,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub price: Option<i32>,
|
|
||||||
pub slot: Option<String>,
|
|
||||||
pub category: Option<String>,
|
|
||||||
pub skill: Option<String>,
|
|
||||||
|
|
||||||
// ... many more fields
|
|
||||||
|
|
||||||
// Nested elements
|
|
||||||
pub stats: Vec<ItemStat>,
|
|
||||||
pub crafting_recipes: Vec<CraftingRecipe>,
|
|
||||||
pub animations: Option<AnimationSet>,
|
|
||||||
pub generate_rules: Vec<GenerateRule>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ItemStat
|
|
||||||
|
|
||||||
Represents item statistics:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct ItemStat {
|
|
||||||
// Damage
|
|
||||||
pub damagephysical: Option<i32>,
|
|
||||||
pub damagemagical: Option<i32>,
|
|
||||||
pub damageranged: Option<i32>,
|
|
||||||
|
|
||||||
// Accuracy
|
|
||||||
pub accuracyphysical: Option<i32>,
|
|
||||||
pub accuracymagical: Option<i32>,
|
|
||||||
pub accuracyranged: Option<i32>,
|
|
||||||
|
|
||||||
// Resistance
|
|
||||||
pub resistancephysical: Option<i32>,
|
|
||||||
pub resistancemagical: Option<i32>,
|
|
||||||
pub resistanceranged: Option<i32>,
|
|
||||||
|
|
||||||
// Core stats
|
|
||||||
pub health: Option<i32>,
|
|
||||||
pub mana: Option<i32>,
|
|
||||||
pub manaregen: Option<i32>,
|
|
||||||
pub healing: Option<i32>,
|
|
||||||
|
|
||||||
// Harvesting
|
|
||||||
pub harvestingspeedwoodcutting: Option<i32>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Example Programs
|
|
||||||
|
|
||||||
Run the demos to see all features in action:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Items only
|
|
||||||
cargo run --example item_database_demo
|
|
||||||
|
|
||||||
# All game data (Items, NPCs, Quests, Harvestables)
|
|
||||||
cargo run --example game_data_demo
|
|
||||||
```
|
|
||||||
|
|
||||||
## Loading NPCs
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use cursebreaker_parser::NpcDatabase;
|
|
||||||
|
|
||||||
let npc_db = NpcDatabase::load_from_xml("Data/XMLs/Npcs/NPCInfo.xml")?;
|
|
||||||
println!("Loaded {} NPCs", npc_db.len());
|
|
||||||
```
|
|
||||||
|
|
||||||
### Querying NPCs
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Get by ID
|
|
||||||
if let Some(npc) = npc_db.get_by_id(1) {
|
|
||||||
println!("Found: {}", npc.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get hostile NPCs
|
|
||||||
let hostile = npc_db.get_hostile();
|
|
||||||
|
|
||||||
// Get interactable NPCs
|
|
||||||
let interactable = npc_db.get_interactable();
|
|
||||||
|
|
||||||
// Get NPCs by tag
|
|
||||||
let undead = npc_db.get_by_tag("Undead");
|
|
||||||
|
|
||||||
// Get shopkeepers
|
|
||||||
let shops = npc_db.get_shopkeepers();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Loading Quests
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use cursebreaker_parser::QuestDatabase;
|
|
||||||
|
|
||||||
let quest_db = QuestDatabase::load_from_xml("Data/XMLs/Quests/Quests.xml")?;
|
|
||||||
println!("Loaded {} quests", quest_db.len());
|
|
||||||
```
|
|
||||||
|
|
||||||
### Querying Quests
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Get by ID
|
|
||||||
if let Some(quest) = quest_db.get_by_id(1) {
|
|
||||||
println!("Quest: {}", quest.name);
|
|
||||||
println!("Phases: {}", quest.phase_count());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get main quests
|
|
||||||
let main_quests = quest_db.get_main_quests();
|
|
||||||
|
|
||||||
// Get side quests
|
|
||||||
let side_quests = quest_db.get_side_quests();
|
|
||||||
|
|
||||||
// Get hidden quests
|
|
||||||
let hidden = quest_db.get_hidden_quests();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Loading Harvestables
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use cursebreaker_parser::HarvestableDatabase;
|
|
||||||
|
|
||||||
let harvestable_db = HarvestableDatabase::load_from_xml("Data/XMLs/Harvestables/HarvestableInfo.xml")?;
|
|
||||||
println!("Loaded {} harvestables", harvestable_db.len());
|
|
||||||
```
|
|
||||||
|
|
||||||
### Querying Harvestables
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Get by type ID
|
|
||||||
if let Some(harvestable) = harvestable_db.get_by_typeid(1) {
|
|
||||||
println!("Found: {}", harvestable.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get by skill
|
|
||||||
let woodcutting = harvestable_db.get_by_skill("Woodcutting");
|
|
||||||
let mining = harvestable_db.get_by_skill("mining");
|
|
||||||
let fishing = harvestable_db.get_by_skill("Fishing");
|
|
||||||
|
|
||||||
// Get trees (harvestables with tree=1)
|
|
||||||
let trees = harvestable_db.get_trees();
|
|
||||||
|
|
||||||
// Get by tool requirement
|
|
||||||
let hatchet_nodes = harvestable_db.get_by_tool("hatchet");
|
|
||||||
let pickaxe_nodes = harvestable_db.get_by_tool("pickaxe");
|
|
||||||
|
|
||||||
// Get by level range
|
|
||||||
let beginner = harvestable_db.get_by_level_range(1, 10);
|
|
||||||
let advanced = harvestable_db.get_by_level_range(50, 100);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Loading Loot Tables
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use cursebreaker_parser::LootDatabase;
|
|
||||||
|
|
||||||
let loot_db = LootDatabase::load_from_xml("Data/XMLs/Loot/Loot.xml")?;
|
|
||||||
println!("Loaded {} loot tables", loot_db.len());
|
|
||||||
```
|
|
||||||
|
|
||||||
### Querying Loot Tables
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Get all loot tables for a specific NPC
|
|
||||||
let npc_id = 45;
|
|
||||||
let tables = loot_db.get_tables_for_npc(npc_id);
|
|
||||||
|
|
||||||
// Get all drops for a specific NPC
|
|
||||||
let drops = loot_db.get_drops_for_npc(npc_id);
|
|
||||||
for drop in drops {
|
|
||||||
println!("Item ID: {}, Rate: {:?}", drop.item, drop.rate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find which NPCs drop a specific item
|
|
||||||
let item_id = 180;
|
|
||||||
let npcs = loot_db.get_npcs_dropping_item(item_id);
|
|
||||||
println!("Item {} drops from {} NPCs", item_id, npcs.len());
|
|
||||||
|
|
||||||
// Get all tables with conditional drops (checks field)
|
|
||||||
let conditional = loot_db.get_conditional_tables();
|
|
||||||
|
|
||||||
// Get all tables with guaranteed drops (rate = 1)
|
|
||||||
let guaranteed = loot_db.get_tables_with_guaranteed_drops();
|
|
||||||
|
|
||||||
// Get all unique item IDs that can drop
|
|
||||||
let droppable_items = loot_db.get_all_droppable_items();
|
|
||||||
|
|
||||||
// Get all NPCs that have loot tables
|
|
||||||
let npcs_with_loot = loot_db.get_all_npcs_with_loot();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cross-referencing Data
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Find items rewarded by quests
|
|
||||||
for quest in quest_db.all_quests() {
|
|
||||||
for reward in &quest.rewards {
|
|
||||||
if let Some(item_id) = reward.item {
|
|
||||||
if let Some(item) = item_db.get_by_id(item_id) {
|
|
||||||
println!("Quest '{}' rewards: {}", quest.name, item.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find NPCs that give quests
|
|
||||||
for npc in npc_db.all_npcs() {
|
|
||||||
if !npc.questmarkers.is_empty() {
|
|
||||||
println!("NPC '{}' has {} quest markers", npc.name, npc.questmarkers.len());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find items that drop from harvestables
|
|
||||||
for harvestable in harvestable_db.all_harvestables() {
|
|
||||||
for drop in &harvestable.drops {
|
|
||||||
if let Some(item) = item_db.get_by_id(drop.id) {
|
|
||||||
println!("'{}' drops: {} (rate: {})",
|
|
||||||
harvestable.name, item.name, drop.droprate.unwrap_or(0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find what items an NPC drops
|
|
||||||
let npc_id = 45;
|
|
||||||
if let Some(npc) = npc_db.get_by_id(npc_id) {
|
|
||||||
let drops = loot_db.get_drops_for_npc(npc_id);
|
|
||||||
println!("NPC '{}' drops {} items:", npc.name, drops.len());
|
|
||||||
for drop in drops {
|
|
||||||
if let Some(item) = item_db.get_by_id(drop.item) {
|
|
||||||
println!(" - {}", item.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find which NPCs drop a specific item
|
|
||||||
let item_id = 180;
|
|
||||||
if let Some(item) = item_db.get_by_id(item_id) {
|
|
||||||
let npcs = loot_db.get_npcs_dropping_item(item_id);
|
|
||||||
println!("Item '{}' drops from:", item.name);
|
|
||||||
for npc_id in npcs {
|
|
||||||
if let Some(npc) = npc_db.get_by_id(npc_id) {
|
|
||||||
println!(" - {}", npc.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Statistics from XML Files
|
|
||||||
|
|
||||||
When loaded from `/home/connor/repos/CBAssets/Data/XMLs/`:
|
|
||||||
|
|
||||||
### Items.xml
|
|
||||||
- **Total Items**: 1,360
|
|
||||||
- **Weapons**: 166
|
|
||||||
- **Armor**: 148
|
|
||||||
- **Consumables**: 294
|
|
||||||
- **Trinkets**: 59
|
|
||||||
- **Bows**: 18
|
|
||||||
- **Magic Items**: 76
|
|
||||||
|
|
||||||
### NPCs/NPCInfo.xml
|
|
||||||
- **Total NPCs**: 1,242
|
|
||||||
- **Hostile NPCs**: 328
|
|
||||||
- **Interactable NPCs**: 512
|
|
||||||
- **Undead**: 71
|
|
||||||
- **Predators**: 13
|
|
||||||
- **Quest Givers**: 108
|
|
||||||
|
|
||||||
### Quests/Quests.xml
|
|
||||||
- **Total Quests**: 108
|
|
||||||
- **Main Quests**: 19
|
|
||||||
- **Side Quests**: 89
|
|
||||||
- **Hidden Quests**: 2
|
|
||||||
- **Unique Quest Reward Items**: 70
|
|
||||||
|
|
||||||
### Harvestables/HarvestableInfo.xml
|
|
||||||
- **Total Harvestables**: 96
|
|
||||||
- **Trees**: 9
|
|
||||||
- **Woodcutting**: 10
|
|
||||||
- **Mining**: 11
|
|
||||||
- **Fishing**: 11
|
|
||||||
- **Alchemy**: 50
|
|
||||||
- **Level 1-10**: 31
|
|
||||||
- **Level 11-50**: 37
|
|
||||||
- **Level 51-100**: 28
|
|
||||||
- **Unique Items from Harvestables**: 98
|
|
||||||
|
|
||||||
### Loot/Loot.xml
|
|
||||||
- **Total Loot Tables**: 175
|
|
||||||
- **NPCs with Loot**: 267
|
|
||||||
- **Droppable Items**: 405
|
|
||||||
- **Tables with Conditional Drops**: 33
|
|
||||||
- **Tables with Guaranteed Drops**: Multiple tables include guaranteed (rate=1) drops
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
cursebreaker-parser/
|
|
||||||
├── src/
|
|
||||||
│ ├── lib.rs # Library exports
|
|
||||||
│ ├── main.rs # Main binary (Unity + XML parsing)
|
|
||||||
│ ├── types/
|
|
||||||
│ │ ├── mod.rs
|
|
||||||
│ │ ├── item.rs # Item data structures
|
|
||||||
│ │ ├── npc.rs # NPC data structures
|
|
||||||
│ │ ├── quest.rs # Quest data structures
|
|
||||||
│ │ ├── harvestable.rs # Harvestable data structures
|
|
||||||
│ │ ├── loot.rs # Loot table data structures
|
|
||||||
│ │ └── interactable_resource.rs
|
|
||||||
│ ├── xml_parser.rs # XML parsing logic (all types)
|
|
||||||
│ ├── item_database.rs # ItemDatabase for runtime access
|
|
||||||
│ ├── npc_database.rs # NpcDatabase for runtime access
|
|
||||||
│ ├── quest_database.rs # QuestDatabase for runtime access
|
|
||||||
│ ├── harvestable_database.rs # HarvestableDatabase for runtime access
|
|
||||||
│ └── loot_database.rs # LootDatabase for runtime access
|
|
||||||
└── examples/
|
|
||||||
├── item_database_demo.rs # Items usage example
|
|
||||||
└── game_data_demo.rs # Full game data example
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dependencies Added
|
|
||||||
|
|
||||||
```toml
|
|
||||||
quick-xml = "0.37" # XML parsing
|
|
||||||
serde = { version = "1.0", features = ["derive"] } # Serialization
|
|
||||||
serde_json = "1.0" # JSON serialization
|
|
||||||
diesel = { version = "2.2", features = ["sqlite"], optional = true } # SQL (optional)
|
|
||||||
thiserror = "1.0" # Error handling
|
|
||||||
```
|
|
||||||
|
|
||||||
## Completed Features
|
|
||||||
|
|
||||||
- ✅ Items (`/XMLs/Items/Items.xml`)
|
|
||||||
- ✅ NPCs (`/XMLs/Npcs/NPCInfo.xml`)
|
|
||||||
- ✅ Quests (`/XMLs/Quests/Quests.xml`)
|
|
||||||
- ✅ Harvestables (`/XMLs/Harvestables/HarvestableInfo.xml`)
|
|
||||||
- ✅ Loot tables (`/XMLs/Loot/Loot.xml`)
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
The same pattern can be extended to parse other XML files:
|
|
||||||
|
|
||||||
- [ ] Maps (`/XMLs/Maps/*.xml`)
|
|
||||||
- [ ] Dialogue (`/XMLs/Dialogue/*.xml`)
|
|
||||||
- [ ] Events (`/XMLs/Events/*.xml`)
|
|
||||||
- [ ] Achievements (`/XMLs/Achievements/*.xml`)
|
|
||||||
- [ ] Traits (`/XMLs/Traits/*.xml`)
|
|
||||||
- [ ] Shops (`/XMLs/Shops/*.xml`)
|
|
||||||
|
|
||||||
Each follows the same pattern:
|
|
||||||
1. Define data structures in `src/types/`
|
|
||||||
2. Create parser in `src/xml_parser.rs`
|
|
||||||
3. Create database wrapper for runtime access
|
|
||||||
4. Add to `lib.rs` exports
|
|
||||||
|
|
||||||
## Integration with Unity Parser
|
|
||||||
|
|
||||||
The main binary (`src/main.rs`) demonstrates integration of both systems:
|
|
||||||
|
|
||||||
1. Load game data from XML files (Items, etc.)
|
|
||||||
2. Parse Unity scenes for game objects
|
|
||||||
3. Cross-reference data (e.g., item IDs in loot spawners)
|
|
||||||
|
|
||||||
This creates a complete game data pipeline from source files to runtime.
|
|
||||||
Binary file not shown.
@@ -1,8 +1,10 @@
|
|||||||
use cursebreaker_parser::{FastTravelDatabase, FastTravelType};
|
use cursebreaker_parser::{FastTravelDatabase, FastTravelType};
|
||||||
|
use std::env;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Load all fast travel types from the directory
|
// Load all fast travel types from the directory
|
||||||
let ft_db = FastTravelDatabase::load_from_directory("/home/connor/repos/CBAssets/Data/XMLs")?;
|
let cb_assets_path = env::var("CB_ASSETS_PATH").unwrap_or_else(|_| "/home/connor/repos/CBAssets".to_string());
|
||||||
|
let ft_db = FastTravelDatabase::load_from_directory(&format!("{}/Data/XMLs", cb_assets_path))?;
|
||||||
|
|
||||||
println!("=== Fast Travel Database Statistics ===");
|
println!("=== Fast Travel Database Statistics ===");
|
||||||
println!("Total locations: {}", ft_db.len());
|
println!("Total locations: {}", ft_db.len());
|
||||||
|
|||||||
@@ -3,17 +3,19 @@
|
|||||||
//! Run with: cargo run --example game_data_demo
|
//! Run with: cargo run --example game_data_demo
|
||||||
|
|
||||||
use cursebreaker_parser::{ItemDatabase, NpcDatabase, QuestDatabase, HarvestableDatabase, LootDatabase};
|
use cursebreaker_parser::{ItemDatabase, NpcDatabase, QuestDatabase, HarvestableDatabase, LootDatabase};
|
||||||
|
use std::env;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
println!("🎮 Cursebreaker Game Data Demo\n");
|
println!("🎮 Cursebreaker Game Data Demo\n");
|
||||||
|
|
||||||
// Load all game data
|
// Load all game data
|
||||||
println!("📚 Loading game data...");
|
println!("📚 Loading game data...");
|
||||||
let item_db = ItemDatabase::load_from_xml("/home/connor/repos/CBAssets/Data/XMLs/Items/Items.xml")?;
|
let cb_assets_path = env::var("CB_ASSETS_PATH").unwrap_or_else(|_| "/home/connor/repos/CBAssets".to_string());
|
||||||
let npc_db = NpcDatabase::load_from_xml("/home/connor/repos/CBAssets/Data/XMLs/Npcs/NPCInfo.xml")?;
|
let item_db = ItemDatabase::load_from_xml(&format!("{}/Data/XMLs/Items/Items.xml", cb_assets_path))?;
|
||||||
let quest_db = QuestDatabase::load_from_xml("/home/connor/repos/CBAssets/Data/XMLs/Quests/Quests.xml")?;
|
let npc_db = NpcDatabase::load_from_xml(&format!("{}/Data/XMLs/Npcs/NPCInfo.xml", cb_assets_path))?;
|
||||||
let harvestable_db = HarvestableDatabase::load_from_xml("/home/connor/repos/CBAssets/Data/XMLs/Harvestables/HarvestableInfo.xml")?;
|
let quest_db = QuestDatabase::load_from_xml(&format!("{}/Data/XMLs/Quests/Quests.xml", cb_assets_path))?;
|
||||||
let loot_db = LootDatabase::load_from_xml("/home/connor/repos/CBAssets/Data/XMLs/Loot/Loot.xml")?;
|
let harvestable_db = HarvestableDatabase::load_from_xml(&format!("{}/Data/XMLs/Harvestables/HarvestableInfo.xml", cb_assets_path))?;
|
||||||
|
let loot_db = LootDatabase::load_from_xml(&format!("{}/Data/XMLs/Loot/Loot.xml", cb_assets_path))?;
|
||||||
|
|
||||||
println!("✅ Loaded {} items", item_db.len());
|
println!("✅ Loaded {} items", item_db.len());
|
||||||
println!("✅ Loaded {} NPCs", npc_db.len());
|
println!("✅ Loaded {} NPCs", npc_db.len());
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
//! Run with: cargo run --example item_database_demo
|
//! Run with: cargo run --example item_database_demo
|
||||||
|
|
||||||
use cursebreaker_parser::ItemDatabase;
|
use cursebreaker_parser::ItemDatabase;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
println!("🎮 Cursebreaker Item Database Demo\n");
|
println!("🎮 Cursebreaker Item Database Demo\n");
|
||||||
|
|
||||||
// Load items from XML
|
// Load items from XML
|
||||||
let items_path = "/home/connor/repos/CBAssets/Data/XMLs/Items/Items.xml";
|
let cb_assets_path = env::var("CB_ASSETS_PATH").unwrap_or_else(|_| "/home/connor/repos/CBAssets".to_string());
|
||||||
|
let items_path = format!("{}/Data/XMLs/Items/Items.xml", cb_assets_path);
|
||||||
println!("📚 Loading items from: {}", items_path);
|
println!("📚 Loading items from: {}", items_path);
|
||||||
|
|
||||||
let item_db = ItemDatabase::load_from_xml(items_path)?;
|
let item_db = ItemDatabase::load_from_xml(items_path)?;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
use cursebreaker_parser::MapDatabase;
|
use cursebreaker_parser::MapDatabase;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Load the Maps.xml file
|
// Load the Maps.xml file
|
||||||
let map_db = MapDatabase::load_from_xml("/home/connor/repos/CBAssets/Data/XMLs/Maps/Maps.xml")?;
|
let cb_assets_path = env::var("CB_ASSETS_PATH").unwrap_or_else(|_| "/home/connor/repos/CBAssets".to_string());
|
||||||
|
let map_db = MapDatabase::load_from_xml(&format!("{}/Data/XMLs/Maps/Maps.xml", cb_assets_path))?;
|
||||||
|
|
||||||
println!("=== Map Database Statistics ===");
|
println!("=== Map Database Statistics ===");
|
||||||
println!("Total maps loaded: {}", map_db.len());
|
println!("Total maps loaded: {}", map_db.len());
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
use cursebreaker_parser::PlayerHouseDatabase;
|
use cursebreaker_parser::PlayerHouseDatabase;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Load all player houses from XML
|
// Load all player houses from XML
|
||||||
|
let cb_assets_path = env::var("CB_ASSETS_PATH").unwrap_or_else(|_| "/home/connor/repos/CBAssets".to_string());
|
||||||
let ph_db = PlayerHouseDatabase::load_from_xml(
|
let ph_db = PlayerHouseDatabase::load_from_xml(
|
||||||
"/home/connor/repos/CBAssets/Data/XMLs/PlayerHouses/PlayerHouses.xml",
|
&format!("{}/Data/XMLs/PlayerHouses/PlayerHouses.xml", cb_assets_path),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
println!("=== Player House Database Statistics ===");
|
println!("=== Player House Database Statistics ===");
|
||||||
|
|||||||
59
cursebreaker-parser/examples/query_world_resources.rs
Normal file
59
cursebreaker-parser/examples/query_world_resources.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
//! Example: Query world resources from the database
|
||||||
|
//!
|
||||||
|
//! Run with: cargo run --example query_world_resources
|
||||||
|
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use diesel::sqlite::SqliteConnection;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Connect to database
|
||||||
|
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "../cursebreaker.db".to_string());
|
||||||
|
let mut conn = SqliteConnection::establish(&database_url)?;
|
||||||
|
|
||||||
|
// Use the schema
|
||||||
|
use cursebreaker_parser::schema::world_resources::dsl::*;
|
||||||
|
|
||||||
|
// Query all resources
|
||||||
|
#[derive(Queryable, Debug)]
|
||||||
|
struct WorldResource {
|
||||||
|
item_id: i32,
|
||||||
|
pos_x: f32,
|
||||||
|
pos_y: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = world_resources
|
||||||
|
.limit(10)
|
||||||
|
.load::<WorldResource>(&mut conn)?;
|
||||||
|
|
||||||
|
println!("Found {} resources (showing first 10):", results.len());
|
||||||
|
println!();
|
||||||
|
|
||||||
|
for resource in results {
|
||||||
|
println!("Resource:");
|
||||||
|
println!(" Item ID: {}", resource.item_id);
|
||||||
|
println!(" Position: ({:.2}, {:.2})", resource.pos_x, resource.pos_y);
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query all resources
|
||||||
|
println!("\n--- All world resources ---");
|
||||||
|
let all_results = world_resources
|
||||||
|
.load::<WorldResource>(&mut conn)?;
|
||||||
|
|
||||||
|
println!("Found {} total resources", all_results.len());
|
||||||
|
|
||||||
|
// Group by item_id
|
||||||
|
use std::collections::HashMap;
|
||||||
|
let mut item_counts: HashMap<i32, usize> = HashMap::new();
|
||||||
|
for resource in &all_results {
|
||||||
|
*item_counts.entry(resource.item_id).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\nResource counts by item ID:");
|
||||||
|
for (i_id, count) in item_counts {
|
||||||
|
println!(" Item {}: {} instances", i_id, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
53
cursebreaker-parser/examples/resource_icons_example.rs
Normal file
53
cursebreaker-parser/examples/resource_icons_example.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
//! Example: Query resource icons from the database
|
||||||
|
//!
|
||||||
|
//! This example shows how to retrieve processed resource icons for harvestables.
|
||||||
|
//! Icons are 64x64 WebP images with white borders.
|
||||||
|
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use diesel::sqlite::SqliteConnection;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Connect to database
|
||||||
|
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "../cursebreaker.db".to_string());
|
||||||
|
let mut conn = SqliteConnection::establish(&database_url)?;
|
||||||
|
|
||||||
|
// Define the structure
|
||||||
|
#[derive(Queryable, Debug)]
|
||||||
|
struct ResourceIcon {
|
||||||
|
item_id: i32,
|
||||||
|
name: String,
|
||||||
|
icon_64: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import schema
|
||||||
|
use cursebreaker_parser::schema::resource_icons::dsl::*;
|
||||||
|
|
||||||
|
// Query all resource icons
|
||||||
|
let icons = resource_icons.load::<ResourceIcon>(&mut conn)?;
|
||||||
|
|
||||||
|
println!("📦 Resource Icons Database");
|
||||||
|
println!("========================\n");
|
||||||
|
println!("Total icons: {}\n", icons.len());
|
||||||
|
|
||||||
|
for icon in icons {
|
||||||
|
println!("Harvestable ID: {}", icon.item_id);
|
||||||
|
println!(" Name: {}", icon.name);
|
||||||
|
println!(" Icon size: {} bytes (WebP format, 64x64 with white border)", icon.icon_64.len());
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example: Get icon for a specific harvestable
|
||||||
|
println!("\n🔍 Looking up Copper Ore (harvestable_id = 2):");
|
||||||
|
let copper_icon = resource_icons
|
||||||
|
.filter(item_id.eq(2))
|
||||||
|
.first::<ResourceIcon>(&mut conn)?;
|
||||||
|
|
||||||
|
println!(" Name: {}", copper_icon.name);
|
||||||
|
println!(" Icon size: {} bytes", copper_icon.icon_64.len());
|
||||||
|
|
||||||
|
// You can save the icon to a file for testing:
|
||||||
|
// std::fs::write("copper_ore.webp", &copper_icon.icon_64)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
use cursebreaker_parser::ShopDatabase;
|
use cursebreaker_parser::ShopDatabase;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Load all shops from XML
|
// Load all shops from XML
|
||||||
|
let cb_assets_path = env::var("CB_ASSETS_PATH").unwrap_or_else(|_| "/home/connor/repos/CBAssets".to_string());
|
||||||
let shop_db = ShopDatabase::load_from_xml(
|
let shop_db = ShopDatabase::load_from_xml(
|
||||||
"/home/connor/repos/CBAssets/Data/XMLs/Shops/Shops.xml",
|
&format!("{}/Data/XMLs/Shops/Shops.xml", cb_assets_path),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
println!("=== Shop Database Statistics ===");
|
println!("=== Shop Database Statistics ===");
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
use cursebreaker_parser::TraitDatabase;
|
use cursebreaker_parser::TraitDatabase;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Load all traits from XML
|
// Load all traits from XML
|
||||||
|
let cb_assets_path = env::var("CB_ASSETS_PATH").unwrap_or_else(|_| "/home/connor/repos/CBAssets".to_string());
|
||||||
let trait_db = TraitDatabase::load_from_xml(
|
let trait_db = TraitDatabase::load_from_xml(
|
||||||
"/home/connor/repos/CBAssets/Data/XMLs/Traits/Traits.xml",
|
&format!("{}/Data/XMLs/Traits/Traits.xml", cb_assets_path),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
println!("=== Trait Database Statistics ===");
|
println!("=== Trait Database Statistics ===");
|
||||||
|
|||||||
140
cursebreaker-parser/examples/verify_world_objects.rs
Normal file
140
cursebreaker-parser/examples/verify_world_objects.rs
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
//! Example: Query world objects from the database
|
||||||
|
//!
|
||||||
|
//! Run with: cargo run --example verify_world_objects
|
||||||
|
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use diesel::sqlite::SqliteConnection;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Connect to database
|
||||||
|
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "../cursebreaker.db".to_string());
|
||||||
|
let mut conn = SqliteConnection::establish(&database_url)?;
|
||||||
|
|
||||||
|
// Query teleporters
|
||||||
|
{
|
||||||
|
use cursebreaker_parser::schema::world_teleporters::dsl::*;
|
||||||
|
|
||||||
|
#[derive(Queryable, Debug)]
|
||||||
|
struct Teleporter {
|
||||||
|
pos_x: f32,
|
||||||
|
pos_y: f32,
|
||||||
|
tp_x: Option<f32>,
|
||||||
|
tp_y: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = world_teleporters.load::<Teleporter>(&mut conn)?;
|
||||||
|
println!("=== World Teleporters ===");
|
||||||
|
println!("Found {} teleporters\n", results.len());
|
||||||
|
for tp in results {
|
||||||
|
print!(" At ({:.2}, {:.2})", tp.pos_x, tp.pos_y);
|
||||||
|
if let (Some(tx), Some(ty)) = (tp.tp_x, tp.tp_y) {
|
||||||
|
println!(" -> teleports to ({:.2}, {:.2})", tx, ty);
|
||||||
|
} else {
|
||||||
|
println!(" -> no destination");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query workbenches
|
||||||
|
{
|
||||||
|
use cursebreaker_parser::schema::world_workbenches::dsl::*;
|
||||||
|
|
||||||
|
#[derive(Queryable, Debug)]
|
||||||
|
struct Workbench {
|
||||||
|
pos_x: f32,
|
||||||
|
pos_y: f32,
|
||||||
|
workbench_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = world_workbenches.load::<Workbench>(&mut conn)?;
|
||||||
|
println!("=== World Workbenches ===");
|
||||||
|
println!("Found {} workbenches\n", results.len());
|
||||||
|
for wb in results {
|
||||||
|
println!(" Workbench ID {} at ({:.2}, {:.2})", wb.workbench_id, wb.pos_x, wb.pos_y);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query loot spawners
|
||||||
|
{
|
||||||
|
use cursebreaker_parser::schema::world_loot::dsl::*;
|
||||||
|
|
||||||
|
#[derive(Queryable, Debug)]
|
||||||
|
struct Loot {
|
||||||
|
pos_x: f32,
|
||||||
|
pos_y: f32,
|
||||||
|
item_id: i32,
|
||||||
|
amount: i32,
|
||||||
|
respawn_time: i32,
|
||||||
|
visibility_checks: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = world_loot.load::<Loot>(&mut conn)?;
|
||||||
|
println!("=== World Loot ===");
|
||||||
|
println!("Found {} loot spawners\n", results.len());
|
||||||
|
for loot in results {
|
||||||
|
println!(" Item {} x{} (respawn: {}s) at ({:.2}, {:.2})",
|
||||||
|
loot.item_id, loot.amount, loot.respawn_time, loot.pos_x, loot.pos_y);
|
||||||
|
if !loot.visibility_checks.is_empty() {
|
||||||
|
println!(" Visibility checks: {}", loot.visibility_checks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query map icons
|
||||||
|
{
|
||||||
|
use cursebreaker_parser::schema::world_map_icons::dsl::*;
|
||||||
|
|
||||||
|
#[derive(Queryable, Debug)]
|
||||||
|
struct MapIcon {
|
||||||
|
pos_x: f32,
|
||||||
|
pos_y: f32,
|
||||||
|
icon_type: i32,
|
||||||
|
icon_size: i32,
|
||||||
|
icon: String,
|
||||||
|
text: String,
|
||||||
|
font_size: i32,
|
||||||
|
hover_text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = world_map_icons.load::<MapIcon>(&mut conn)?;
|
||||||
|
println!("=== World Map Icons ===");
|
||||||
|
println!("Found {} map icons\n", results.len());
|
||||||
|
for map_icon in results {
|
||||||
|
print!(" Type {} at ({:.2}, {:.2})", map_icon.icon_type, map_icon.pos_x, map_icon.pos_y);
|
||||||
|
if !map_icon.text.is_empty() {
|
||||||
|
print!(" - Text: \"{}\"", map_icon.text);
|
||||||
|
}
|
||||||
|
if !map_icon.hover_text.is_empty() {
|
||||||
|
print!(" - Hover: \"{}\"", map_icon.hover_text);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query map name changers
|
||||||
|
{
|
||||||
|
use cursebreaker_parser::schema::world_map_name_changers::dsl::*;
|
||||||
|
|
||||||
|
#[derive(Queryable, Debug)]
|
||||||
|
struct MapNameChanger {
|
||||||
|
pos_x: f32,
|
||||||
|
pos_y: f32,
|
||||||
|
map_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = world_map_name_changers.load::<MapNameChanger>(&mut conn)?;
|
||||||
|
println!("=== World Map Name Changers ===");
|
||||||
|
println!("Found {} map name changers\n", results.len());
|
||||||
|
for changer in results {
|
||||||
|
println!(" \"{}\" at ({:.2}, {:.2})", changer.map_name, changer.pos_x, changer.pos_y);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_merged_tiles_zoom_coords;
|
||||||
|
DROP TABLE IF EXISTS merged_tiles;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- Create merged_tiles table for storing merged map tiles at different zoom levels
|
||||||
|
-- Zoom level 2: 1x1 tiles (512px original tiles)
|
||||||
|
-- Zoom level 1: 2x2 tiles merged into 512px
|
||||||
|
-- Zoom level 0: 4x4 tiles merged into 512px
|
||||||
|
|
||||||
|
CREATE TABLE merged_tiles (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
-- Tile coordinates at this zoom level
|
||||||
|
x INTEGER NOT NULL,
|
||||||
|
y INTEGER NOT NULL,
|
||||||
|
-- Zoom level (0 = most zoomed out, 2 = most zoomed in)
|
||||||
|
zoom_level INTEGER NOT NULL,
|
||||||
|
-- Number of original tiles merged (1, 4, or 16)
|
||||||
|
merge_factor INTEGER NOT NULL,
|
||||||
|
-- Dimensions of the merged image
|
||||||
|
width INTEGER NOT NULL,
|
||||||
|
height INTEGER NOT NULL,
|
||||||
|
-- WebP image data (lossless compression)
|
||||||
|
webp_data BLOB NOT NULL,
|
||||||
|
webp_size INTEGER NOT NULL,
|
||||||
|
-- Metadata
|
||||||
|
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
-- Track which original tiles were merged (for debugging)
|
||||||
|
source_tiles TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Unique constraint on zoom level + coordinates
|
||||||
|
UNIQUE(zoom_level, x, y)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for fast lookups
|
||||||
|
CREATE INDEX idx_merged_tiles_zoom_coords ON merged_tiles(zoom_level, x, y);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- This migration cannot be rolled back automatically
|
||||||
|
-- You would need to re-run the image-parser to restore data
|
||||||
|
DROP INDEX IF EXISTS idx_minimap_tiles_coords;
|
||||||
|
DROP INDEX IF EXISTS idx_minimap_tiles_zoom_coords;
|
||||||
|
DROP TABLE IF EXISTS minimap_tiles;
|
||||||
|
|
||||||
|
-- Restore old structure (data will be lost)
|
||||||
|
CREATE TABLE minimap_tiles (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
x INTEGER NOT NULL,
|
||||||
|
y INTEGER NOT NULL,
|
||||||
|
original_width INTEGER NOT NULL,
|
||||||
|
original_height INTEGER NOT NULL,
|
||||||
|
original_file_size INTEGER,
|
||||||
|
webp_512 BLOB NOT NULL,
|
||||||
|
webp_256 BLOB NOT NULL,
|
||||||
|
webp_128 BLOB NOT NULL,
|
||||||
|
webp_64 BLOB NOT NULL,
|
||||||
|
webp_512_size INTEGER NOT NULL,
|
||||||
|
webp_256_size INTEGER NOT NULL,
|
||||||
|
webp_128_size INTEGER NOT NULL,
|
||||||
|
webp_64_size INTEGER NOT NULL,
|
||||||
|
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
source_path TEXT NOT NULL,
|
||||||
|
UNIQUE(x, y)
|
||||||
|
);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- Drop merged_tiles table (no longer needed)
|
||||||
|
DROP TABLE IF EXISTS merged_tiles;
|
||||||
|
DROP INDEX IF EXISTS idx_merged_tiles_zoom_coords;
|
||||||
|
|
||||||
|
-- Drop old minimap_tiles table
|
||||||
|
DROP TABLE IF EXISTS minimap_tiles;
|
||||||
|
|
||||||
|
-- Create new minimap_tiles table with simplified structure
|
||||||
|
CREATE TABLE minimap_tiles (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
-- Tile coordinates (at zoom level 2, original tile coords)
|
||||||
|
x INTEGER NOT NULL,
|
||||||
|
y INTEGER NOT NULL,
|
||||||
|
-- Zoom level (0 = 4x4 merged, 1 = 2x2 merged, 2 = original)
|
||||||
|
zoom INTEGER NOT NULL,
|
||||||
|
-- Image dimensions (always 512x512 for merged tiles)
|
||||||
|
width INTEGER NOT NULL,
|
||||||
|
height INTEGER NOT NULL,
|
||||||
|
-- Original file size (only for zoom=2)
|
||||||
|
original_file_size INTEGER,
|
||||||
|
-- WebP image data (lossless)
|
||||||
|
image BLOB NOT NULL,
|
||||||
|
image_size INTEGER NOT NULL,
|
||||||
|
-- Metadata
|
||||||
|
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
source_path TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Unique constraint on coordinates + zoom
|
||||||
|
UNIQUE(x, y, zoom)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for fast lookups
|
||||||
|
CREATE INDEX idx_minimap_tiles_zoom_coords ON minimap_tiles(zoom, x, y);
|
||||||
|
CREATE INDEX idx_minimap_tiles_coords ON minimap_tiles(x, y);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- Undo the expand_items migration
|
||||||
|
|
||||||
|
-- Drop crafting tables
|
||||||
|
DROP INDEX IF EXISTS idx_crafting_recipe_items_item;
|
||||||
|
DROP TABLE IF EXISTS crafting_recipe_items;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_crafting_recipes_workbench;
|
||||||
|
DROP INDEX IF EXISTS idx_crafting_recipes_level;
|
||||||
|
DROP INDEX IF EXISTS idx_crafting_recipes_skill;
|
||||||
|
DROP INDEX IF EXISTS idx_crafting_recipes_product;
|
||||||
|
DROP TABLE IF EXISTS crafting_recipes;
|
||||||
|
|
||||||
|
-- Drop item indexes
|
||||||
|
DROP INDEX IF EXISTS idx_items_skill;
|
||||||
|
DROP INDEX IF EXISTS idx_items_price;
|
||||||
|
DROP INDEX IF EXISTS idx_items_level;
|
||||||
|
DROP INDEX IF EXISTS idx_items_type;
|
||||||
|
|
||||||
|
-- Note: SQLite doesn't support DROP COLUMN in ALTER TABLE
|
||||||
|
-- To truly revert, we'd need to recreate the table without the columns
|
||||||
|
-- For now, we'll leave the columns in place (they won't hurt with defaults)
|
||||||
|
-- If you need a full revert, you'd need to:
|
||||||
|
-- 1. CREATE TABLE items_backup (id, name, data)
|
||||||
|
-- 2. INSERT INTO items_backup SELECT id, name, data FROM items
|
||||||
|
-- 3. DROP TABLE items
|
||||||
|
-- 4. ALTER TABLE items_backup RENAME TO items
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
-- Add core columns to items table for efficient querying
|
||||||
|
|
||||||
|
-- Item classification
|
||||||
|
ALTER TABLE items ADD COLUMN item_type TEXT NOT NULL DEFAULT 'resource';
|
||||||
|
ALTER TABLE items ADD COLUMN level INTEGER NOT NULL DEFAULT 1;
|
||||||
|
|
||||||
|
-- Economy
|
||||||
|
ALTER TABLE items ADD COLUMN price INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Stacking and storage
|
||||||
|
ALTER TABLE items ADD COLUMN max_stack INTEGER NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE items ADD COLUMN storage_size INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Skills
|
||||||
|
ALTER TABLE items ADD COLUMN skill TEXT NOT NULL DEFAULT 'none';
|
||||||
|
ALTER TABLE items ADD COLUMN tool TEXT NOT NULL DEFAULT 'none';
|
||||||
|
|
||||||
|
-- Visual/UI
|
||||||
|
ALTER TABLE items ADD COLUMN description TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- Boolean flags (stored as INTEGER: 0=false, 1=true)
|
||||||
|
ALTER TABLE items ADD COLUMN two_handed INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE items ADD COLUMN undroppable INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE items ADD COLUMN undroppable_on_death INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE items ADD COLUMN unequip_destroy INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE items ADD COLUMN generate_icon INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE items ADD COLUMN hide_milestone INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE items ADD COLUMN cannot_craft_exceptional INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE items ADD COLUMN storage_all_items INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Ability and item IDs
|
||||||
|
ALTER TABLE items ADD COLUMN ability_id INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE items ADD COLUMN special_ability INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE items ADD COLUMN learn_ability_id INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE items ADD COLUMN book_id INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE items ADD COLUMN swap_item INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Create indexes for commonly queried columns
|
||||||
|
CREATE INDEX idx_items_type ON items(item_type);
|
||||||
|
CREATE INDEX idx_items_level ON items(level);
|
||||||
|
CREATE INDEX idx_items_price ON items(price);
|
||||||
|
CREATE INDEX idx_items_skill ON items(skill);
|
||||||
|
|
||||||
|
-- Crafting recipes table
|
||||||
|
CREATE TABLE crafting_recipes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
product_item_id INTEGER NOT NULL,
|
||||||
|
skill TEXT NOT NULL,
|
||||||
|
level INTEGER NOT NULL,
|
||||||
|
workbench_id INTEGER NOT NULL,
|
||||||
|
xp INTEGER NOT NULL DEFAULT 0,
|
||||||
|
unlocked_by_default INTEGER NOT NULL DEFAULT 1,
|
||||||
|
checks TEXT, -- nullable, for conditional recipes
|
||||||
|
FOREIGN KEY (product_item_id) REFERENCES items(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_crafting_recipes_product ON crafting_recipes(product_item_id);
|
||||||
|
CREATE INDEX idx_crafting_recipes_skill ON crafting_recipes(skill);
|
||||||
|
CREATE INDEX idx_crafting_recipes_level ON crafting_recipes(level);
|
||||||
|
CREATE INDEX idx_crafting_recipes_workbench ON crafting_recipes(workbench_id);
|
||||||
|
|
||||||
|
-- Crafting recipe ingredients (many-to-many)
|
||||||
|
CREATE TABLE crafting_recipe_items (
|
||||||
|
recipe_id INTEGER NOT NULL,
|
||||||
|
item_id INTEGER NOT NULL,
|
||||||
|
amount INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (recipe_id, item_id),
|
||||||
|
FOREIGN KEY (recipe_id) REFERENCES crafting_recipes(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (item_id) REFERENCES items(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_crafting_recipe_items_item ON crafting_recipe_items(item_id);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Undo the add_item_images migration
|
||||||
|
|
||||||
|
-- Note: SQLite doesn't support DROP COLUMN in ALTER TABLE
|
||||||
|
-- The icon columns will remain but can be set to NULL
|
||||||
|
-- To truly revert, you would need to recreate the table without the image columns
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Add item icon columns (WebP format)
|
||||||
|
-- These store the processed WebP images at different resolutions
|
||||||
|
|
||||||
|
ALTER TABLE items ADD COLUMN icon_large BLOB; -- 256x256 WebP
|
||||||
|
ALTER TABLE items ADD COLUMN icon_medium BLOB; -- 64x64 WebP
|
||||||
|
ALTER TABLE items ADD COLUMN icon_small BLOB; -- 16x16 WebP
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Undo the add_item_stats migration
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_item_stats_type_value;
|
||||||
|
DROP INDEX IF EXISTS idx_item_stats_value;
|
||||||
|
DROP INDEX IF EXISTS idx_item_stats_stat_type;
|
||||||
|
DROP TABLE IF EXISTS item_stats;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Create item_stats table for normalized stat storage
|
||||||
|
CREATE TABLE item_stats (
|
||||||
|
item_id INTEGER NOT NULL,
|
||||||
|
stat_type TEXT NOT NULL,
|
||||||
|
value REAL NOT NULL,
|
||||||
|
PRIMARY KEY (item_id, stat_type),
|
||||||
|
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for querying
|
||||||
|
CREATE INDEX idx_item_stats_stat_type ON item_stats(stat_type);
|
||||||
|
CREATE INDEX idx_item_stats_value ON item_stats(value);
|
||||||
|
|
||||||
|
-- Index for finding items by stat value ranges
|
||||||
|
CREATE INDEX idx_item_stats_type_value ON item_stats(stat_type, value);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE world_resources;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- World resources table - stores harvestable resources from Unity scenes
|
||||||
|
CREATE TABLE world_resources (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
item_id INTEGER NOT NULL,
|
||||||
|
scene_name TEXT NOT NULL,
|
||||||
|
pos_x REAL NOT NULL,
|
||||||
|
pos_y REAL NOT NULL,
|
||||||
|
pos_z REAL NOT NULL,
|
||||||
|
object_name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_world_resources_item_id ON world_resources(item_id);
|
||||||
|
CREATE INDEX idx_world_resources_scene ON world_resources(scene_name);
|
||||||
|
CREATE INDEX idx_world_resources_position ON world_resources(pos_x, pos_z);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Revert to original structure
|
||||||
|
DROP TABLE world_resources;
|
||||||
|
|
||||||
|
CREATE TABLE world_resources (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
item_id INTEGER NOT NULL,
|
||||||
|
scene_name TEXT NOT NULL,
|
||||||
|
pos_x REAL NOT NULL,
|
||||||
|
pos_y REAL NOT NULL,
|
||||||
|
pos_z REAL NOT NULL,
|
||||||
|
object_name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_world_resources_item_id ON world_resources(item_id);
|
||||||
|
CREATE INDEX idx_world_resources_scene ON world_resources(scene_name);
|
||||||
|
CREATE INDEX idx_world_resources_position ON world_resources(pos_x, pos_z);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Drop the old table
|
||||||
|
DROP TABLE world_resources;
|
||||||
|
|
||||||
|
-- Recreate with simplified structure - no id, no scene_name, no object_name, only 2D coordinates
|
||||||
|
CREATE TABLE world_resources (
|
||||||
|
item_id INTEGER NOT NULL,
|
||||||
|
pos_x REAL NOT NULL,
|
||||||
|
pos_y REAL NOT NULL,
|
||||||
|
PRIMARY KEY (item_id, pos_x, pos_y)
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
|
||||||
|
CREATE INDEX idx_world_resources_item_id ON world_resources(item_id);
|
||||||
|
CREATE INDEX idx_world_resources_position ON world_resources(pos_x, pos_y);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Drop resource_icons table
|
||||||
|
DROP TABLE resource_icons;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Create resource_icons table to store processed item icons for world resources
|
||||||
|
CREATE TABLE resource_icons (
|
||||||
|
item_id INTEGER PRIMARY KEY NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
icon_64 BLOB NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_resource_icons_name ON resource_icons(name);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Revert to the simple harvestables table
|
||||||
|
DROP TABLE IF EXISTS harvestable_drops;
|
||||||
|
DROP TABLE IF EXISTS harvestables;
|
||||||
|
|
||||||
|
CREATE TABLE harvestables (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- Restructure harvestables table to store expanded data
|
||||||
|
DROP TABLE IF EXISTS harvestables;
|
||||||
|
|
||||||
|
CREATE TABLE harvestables (
|
||||||
|
id INTEGER PRIMARY KEY NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
comment TEXT NOT NULL,
|
||||||
|
level INTEGER NOT NULL,
|
||||||
|
skill TEXT NOT NULL,
|
||||||
|
tool TEXT NOT NULL,
|
||||||
|
min_health INTEGER NOT NULL,
|
||||||
|
max_health INTEGER NOT NULL,
|
||||||
|
harvesttime INTEGER NOT NULL,
|
||||||
|
hittime INTEGER NOT NULL,
|
||||||
|
respawntime INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create harvestable_drops table
|
||||||
|
CREATE TABLE harvestable_drops (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
harvestable_id INTEGER NOT NULL,
|
||||||
|
item_id INTEGER NOT NULL,
|
||||||
|
minamount INTEGER NOT NULL,
|
||||||
|
maxamount INTEGER NOT NULL,
|
||||||
|
droprate INTEGER NOT NULL,
|
||||||
|
droprateboost INTEGER NOT NULL,
|
||||||
|
amountboost INTEGER NOT NULL,
|
||||||
|
comment TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (harvestable_id) REFERENCES harvestables(id),
|
||||||
|
FOREIGN KEY (item_id) REFERENCES items(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_harvestable_drops_harvestable_id ON harvestable_drops(harvestable_id);
|
||||||
|
CREATE INDEX idx_harvestable_drops_item_id ON harvestable_drops(item_id);
|
||||||
|
CREATE INDEX idx_harvestables_skill ON harvestables(skill);
|
||||||
|
CREATE INDEX idx_harvestables_tool ON harvestables(tool);
|
||||||
|
CREATE INDEX idx_harvestables_level ON harvestables(level);
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Drop world scene object tables
|
||||||
|
DROP TABLE world_teleporters;
|
||||||
|
DROP TABLE world_workbenches;
|
||||||
|
DROP TABLE world_loot;
|
||||||
|
DROP TABLE world_map_icons;
|
||||||
|
DROP TABLE world_map_name_changers;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
-- Create world_teleporters table
|
||||||
|
CREATE TABLE world_teleporters (
|
||||||
|
pos_x REAL NOT NULL,
|
||||||
|
pos_y REAL NOT NULL,
|
||||||
|
tp_x REAL,
|
||||||
|
tp_y REAL,
|
||||||
|
PRIMARY KEY (pos_x, pos_y)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create world_workbenches table
|
||||||
|
CREATE TABLE world_workbenches (
|
||||||
|
pos_x REAL NOT NULL,
|
||||||
|
pos_y REAL NOT NULL,
|
||||||
|
workbench_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (pos_x, pos_y)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create world_loot table
|
||||||
|
CREATE TABLE world_loot (
|
||||||
|
pos_x REAL NOT NULL,
|
||||||
|
pos_y REAL NOT NULL,
|
||||||
|
item_id INTEGER NOT NULL,
|
||||||
|
amount INTEGER NOT NULL,
|
||||||
|
respawn_time INTEGER NOT NULL,
|
||||||
|
visibility_checks TEXT NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (pos_x, pos_y)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create world_map_icons table
|
||||||
|
CREATE TABLE world_map_icons (
|
||||||
|
pos_x REAL NOT NULL,
|
||||||
|
pos_y REAL NOT NULL,
|
||||||
|
icon_type INTEGER NOT NULL,
|
||||||
|
icon_size INTEGER NOT NULL,
|
||||||
|
icon TEXT NOT NULL,
|
||||||
|
text TEXT NOT NULL DEFAULT '',
|
||||||
|
font_size INTEGER NOT NULL,
|
||||||
|
hover_text TEXT NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (pos_x, pos_y)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create world_map_name_changers table
|
||||||
|
CREATE TABLE world_map_name_changers (
|
||||||
|
pos_x REAL NOT NULL,
|
||||||
|
pos_y REAL NOT NULL,
|
||||||
|
map_name TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (pos_x, pos_y)
|
||||||
|
);
|
||||||
76
cursebreaker-parser/src/bin/image-parser.rs
Normal file
76
cursebreaker-parser/src/bin/image-parser.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
//! Image Parser - Processes minimap tiles and generates all zoom levels
|
||||||
|
//!
|
||||||
|
//! This binary handles:
|
||||||
|
//! - Loading minimap tile images from PNG files
|
||||||
|
//! - Converting to lossless WebP format (zoom level 2)
|
||||||
|
//! - Generating merged tiles for zoom level 1 (2x2)
|
||||||
|
//! - Generating merged tiles for zoom level 0 (4x4)
|
||||||
|
//! - Storing all tiles in the SQLite database
|
||||||
|
//! - Generating statistics about storage and compression
|
||||||
|
|
||||||
|
use cursebreaker_parser::MinimapDatabase;
|
||||||
|
use log::{info, error, LevelFilter};
|
||||||
|
use unity_parser::log::DedupLogger;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let logger = DedupLogger::new();
|
||||||
|
log::set_boxed_logger(Box::new(logger))
|
||||||
|
.map(|()| log::set_max_level(LevelFilter::Trace))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
info!("🎮 Cursebreaker - Image Parser");
|
||||||
|
info!("Generates all zoom levels (0, 1, 2) with merged tiles");
|
||||||
|
info!("⚠️ Will override existing database entries\n");
|
||||||
|
|
||||||
|
// Process minimap tiles
|
||||||
|
info!("🗺️ Processing minimap tiles...");
|
||||||
|
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "cursebreaker.db".to_string());
|
||||||
|
let minimap_db = MinimapDatabase::new(database_url);
|
||||||
|
|
||||||
|
let cb_assets_path = env::var("CB_ASSETS_PATH")
|
||||||
|
.unwrap_or_else(|_| "/home/connor/repos/CBAssets".to_string());
|
||||||
|
let minimap_path = format!("{}/Data/Textures/MinimapSquares", cb_assets_path);
|
||||||
|
|
||||||
|
match minimap_db.load_from_directory(&minimap_path, &cb_assets_path) {
|
||||||
|
Ok(total_count) => {
|
||||||
|
info!("\n✅ Processed {} total tiles (all zoom levels)", total_count);
|
||||||
|
|
||||||
|
// Get statistics
|
||||||
|
if let Ok(stats) = minimap_db.get_storage_stats() {
|
||||||
|
info!("\n=== Storage Statistics ===");
|
||||||
|
info!("Original PNG total: {} MB", stats.total_original_size / 1_048_576);
|
||||||
|
info!("WebP total: {} MB", stats.total_webp_size() / 1_048_576);
|
||||||
|
info!("Compression ratio: {:.2}%\n", stats.compression_ratio());
|
||||||
|
|
||||||
|
info!("=== Tiles Per Zoom Level ===");
|
||||||
|
info!("Zoom 2 (original): {} tiles ({} MB)",
|
||||||
|
stats.zoom2_count,
|
||||||
|
stats.zoom2_size / 1_048_576
|
||||||
|
);
|
||||||
|
info!("Zoom 1 (2x2 merged): {} tiles ({} MB)",
|
||||||
|
stats.zoom1_count,
|
||||||
|
stats.zoom1_size / 1_048_576
|
||||||
|
);
|
||||||
|
info!("Zoom 0 (4x4 merged): {} tiles ({} MB)",
|
||||||
|
stats.zoom0_count,
|
||||||
|
stats.zoom0_size / 1_048_576
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(bounds) = minimap_db.get_map_bounds() {
|
||||||
|
info!("\n=== Map Bounds ===");
|
||||||
|
info!("Min (x,y): {:?}", bounds.0);
|
||||||
|
info!("Max (x,y): {:?}", bounds.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to process minimap tiles: {}", e);
|
||||||
|
return Err(Box::new(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::logger().flush();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
451
cursebreaker-parser/src/bin/scene-parser.rs
Normal file
451
cursebreaker-parser/src/bin/scene-parser.rs
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
//! Scene Parser - Parses Unity scenes and extracts game objects
|
||||||
|
//!
|
||||||
|
//! This binary handles:
|
||||||
|
//! - Initializing the Unity project
|
||||||
|
//! - Parsing Unity scenes with type filtering
|
||||||
|
//! - Extracting Interactable_Resource components only
|
||||||
|
//! - Computing world transforms
|
||||||
|
//! - Saving resource locations to the database
|
||||||
|
//! - Processing and saving item icons for resources
|
||||||
|
|
||||||
|
use cursebreaker_parser::{
|
||||||
|
InteractableResource, InteractableTeleporter, InteractableWorkbench,
|
||||||
|
LootSpawner, MapIcon, MapNameChanger, ImageProcessor, OutlineConfig
|
||||||
|
};
|
||||||
|
use unity_parser::{UnityProject, TypeFilter};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use unity_parser::log::DedupLogger;
|
||||||
|
use log::{info, error, warn, LevelFilter};
|
||||||
|
use std::env;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use diesel::sqlite::SqliteConnection;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let logger = DedupLogger::new();
|
||||||
|
log::set_boxed_logger(Box::new(logger))
|
||||||
|
.map(|()| log::set_max_level(LevelFilter::Trace))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
info!("🎮 Cursebreaker - Scene Parser");
|
||||||
|
|
||||||
|
let cb_assets_path = env::var("CB_ASSETS_PATH").unwrap_or_else(|_| "/home/connor/repos/CBAssets".to_string());
|
||||||
|
|
||||||
|
// Initialize Unity project once - scans entire project for GUID mappings
|
||||||
|
let project_root = Path::new(&cb_assets_path);
|
||||||
|
info!("\n📦 Initializing Unity project from: {}", project_root.display());
|
||||||
|
|
||||||
|
let project = UnityProject::from_path(project_root)?;
|
||||||
|
|
||||||
|
// Create type filter to only parse GameObject, Transform, and InteractableResource MonoBehaviour
|
||||||
|
info!("🔍 Setting up type filter:");
|
||||||
|
info!(" • Unity types: GameObject, Transform");
|
||||||
|
info!(" • Custom MonoBehaviours: InteractableResource");
|
||||||
|
let type_filter = TypeFilter::new(
|
||||||
|
vec!["GameObject", "Transform", "PrefabInstance"],
|
||||||
|
vec!["InteractableResource", "InteractableTeleporter", "InteractableWorkbench", "LootSpawner", "MapIcon", "MapNameChanger"]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now parse the scene using the pre-built GUID resolvers with filtering
|
||||||
|
let scene_path = "_GameAssets/Scenes/Tiles/10_3.unity";
|
||||||
|
info!("📁 Parsing scene: {}", scene_path);
|
||||||
|
|
||||||
|
log::logger().flush();
|
||||||
|
|
||||||
|
// Parse the scene using the project with type filtering
|
||||||
|
match project.parse_scene_filtered(scene_path, Some(&type_filter)) {
|
||||||
|
Ok(mut scene) => {
|
||||||
|
info!("✅ Scene parsed successfully!");
|
||||||
|
info!(" Total entities: {}", scene.entity_map.len());
|
||||||
|
|
||||||
|
// Post-processing: Compute world transforms
|
||||||
|
info!("🔄 Computing world transforms...");
|
||||||
|
unity_parser::compute_world_transforms(&mut scene.world, &scene.entity_map);
|
||||||
|
info!(" ✓ World transforms computed");
|
||||||
|
|
||||||
|
// Save resources to database
|
||||||
|
info!("💾 Saving resources to database...");
|
||||||
|
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "../cursebreaker.db".to_string());
|
||||||
|
let mut conn = SqliteConnection::establish(&database_url)?;
|
||||||
|
|
||||||
|
// Use diesel schema
|
||||||
|
use cursebreaker_parser::schema::world_resources;
|
||||||
|
|
||||||
|
// Clear the entire table (it's regenerated each run)
|
||||||
|
diesel::delete(world_resources::table).execute(&mut conn)?;
|
||||||
|
|
||||||
|
let mut resource_count = 0;
|
||||||
|
|
||||||
|
// Insert all resources in a transaction
|
||||||
|
conn.transaction::<_, diesel::result::Error, _>(|conn| {
|
||||||
|
scene.world
|
||||||
|
.query_all::<(&InteractableResource, &unity_parser::WorldTransform, &unity_parser::GameObject)>()
|
||||||
|
.for_each(|(resource, transform, object)| {
|
||||||
|
let world_pos = transform.position();
|
||||||
|
|
||||||
|
info!(" 📦 Resource: \"{}\"", object.name);
|
||||||
|
info!(" • typeId: {}", resource.type_id);
|
||||||
|
info!(" • Position: ({:.2}, {:.2})", world_pos.x, world_pos.z);
|
||||||
|
|
||||||
|
// Insert: store x and z (as y) coordinates only
|
||||||
|
let _ = diesel::insert_into(world_resources::table)
|
||||||
|
.values((
|
||||||
|
world_resources::item_id.eq(resource.type_id as i32),
|
||||||
|
world_resources::pos_x.eq(world_pos.x as f32),
|
||||||
|
world_resources::pos_y.eq(world_pos.z as f32),
|
||||||
|
))
|
||||||
|
.execute(conn);
|
||||||
|
|
||||||
|
resource_count += 1;
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
info!("✅ Saved {} resources to database", resource_count);
|
||||||
|
log::logger().flush();
|
||||||
|
|
||||||
|
// Process and save item icons
|
||||||
|
info!("🎨 Processing item icons...");
|
||||||
|
process_item_icons(&cb_assets_path, &mut conn, &scene)?;
|
||||||
|
|
||||||
|
// Save other world objects
|
||||||
|
info!("🗺️ Saving teleporters...");
|
||||||
|
save_teleporters(&mut conn, &scene)?;
|
||||||
|
|
||||||
|
info!("🔨 Saving workbenches...");
|
||||||
|
save_workbenches(&mut conn, &scene)?;
|
||||||
|
|
||||||
|
info!("💰 Saving loot spawners...");
|
||||||
|
save_loot_spawners(&mut conn, &scene)?;
|
||||||
|
|
||||||
|
info!("📍 Saving map icons...");
|
||||||
|
save_map_icons(&mut conn, &scene)?;
|
||||||
|
|
||||||
|
info!("🏷️ Saving map name changers...");
|
||||||
|
save_map_name_changers(&mut conn, &scene)?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Parse error: {}", e);
|
||||||
|
return Err(Box::new(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::logger().flush();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process item icons for all resources in the scene
|
||||||
|
fn process_item_icons(
|
||||||
|
cb_assets_path: &str,
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
scene: &unity_parser::UnityScene,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use cursebreaker_parser::schema::{resource_icons, items, harvestables, harvestable_drops};
|
||||||
|
|
||||||
|
// Collect unique harvestable IDs from resources
|
||||||
|
let mut unique_harvestables: HashMap<i32, String> = HashMap::new();
|
||||||
|
|
||||||
|
scene.world
|
||||||
|
.query_all::<(&InteractableResource, &unity_parser::GameObject)>()
|
||||||
|
.for_each(|(resource, object)| {
|
||||||
|
unique_harvestables.entry(resource.type_id as i32)
|
||||||
|
.or_insert_with(|| object.name.to_string());
|
||||||
|
});
|
||||||
|
|
||||||
|
info!(" Found {} unique harvestable types", unique_harvestables.len());
|
||||||
|
|
||||||
|
// Clear existing resource icons (regenerated each run)
|
||||||
|
diesel::delete(resource_icons::table).execute(conn)?;
|
||||||
|
|
||||||
|
// Create image processor with white outline
|
||||||
|
let processor = ImageProcessor::default();
|
||||||
|
let outline_config = OutlineConfig::white(4);
|
||||||
|
|
||||||
|
let mut processed_count = 0;
|
||||||
|
let mut failed_count = 0;
|
||||||
|
|
||||||
|
// Process each unique harvestable
|
||||||
|
for (harvestable_id, default_name) in unique_harvestables.iter() {
|
||||||
|
// Get the harvestable name
|
||||||
|
let harvestable_name: String = harvestables::table
|
||||||
|
.filter(harvestables::id.eq(harvestable_id))
|
||||||
|
.select(harvestables::name)
|
||||||
|
.first(conn)
|
||||||
|
.unwrap_or_else(|_| default_name.clone());
|
||||||
|
|
||||||
|
// Get the first item drop for this harvestable
|
||||||
|
let item_id_result: Result<i32, _> = harvestable_drops::table
|
||||||
|
.filter(harvestable_drops::harvestable_id.eq(harvestable_id))
|
||||||
|
.select(harvestable_drops::item_id)
|
||||||
|
.order(harvestable_drops::id.asc())
|
||||||
|
.first(conn);
|
||||||
|
|
||||||
|
let item_id = match item_id_result {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
warn!(" ⚠️ No drops found for harvestable {} ({})", harvestable_id, harvestable_name);
|
||||||
|
failed_count += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the item name
|
||||||
|
let item_name: String = items::table
|
||||||
|
.filter(items::id.eq(&item_id))
|
||||||
|
.select(items::name)
|
||||||
|
.first(conn)
|
||||||
|
.unwrap_or_else(|_| format!("Item {}", item_id));
|
||||||
|
|
||||||
|
// Construct icon path using the item_id from the drop
|
||||||
|
let icon_path = PathBuf::from(cb_assets_path)
|
||||||
|
.join("Data/Textures/ItemIcons")
|
||||||
|
.join(format!("{}.png", item_id));
|
||||||
|
|
||||||
|
if !icon_path.exists() {
|
||||||
|
warn!(" ⚠️ Icon not found for harvestable {} ({}) -> item {} ({}): {}",
|
||||||
|
harvestable_id, harvestable_name, item_id, item_name, icon_path.display());
|
||||||
|
failed_count += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the icon: resize to 64px with white outline
|
||||||
|
match processor.process_image(&icon_path, &[64], None, Some(&outline_config)) {
|
||||||
|
Ok(processed) => {
|
||||||
|
if let Some(icon_data) = processed.get(64) {
|
||||||
|
// Insert into database using harvestable_id as the key
|
||||||
|
match diesel::insert_into(resource_icons::table)
|
||||||
|
.values((
|
||||||
|
resource_icons::item_id.eq(harvestable_id),
|
||||||
|
resource_icons::name.eq(&harvestable_name),
|
||||||
|
resource_icons::icon_64.eq(icon_data.as_slice()),
|
||||||
|
))
|
||||||
|
.execute(conn)
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
info!(" ✓ Harvestable {} ({}) -> Item {} ({}): {} bytes",
|
||||||
|
harvestable_id, harvestable_name, item_id, item_name, icon_data.len());
|
||||||
|
processed_count += 1;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(" ⚠️ Failed to insert icon for harvestable {} ({}): {}",
|
||||||
|
harvestable_id, harvestable_name, e);
|
||||||
|
failed_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(" ⚠️ Failed to process icon for harvestable {} ({}) -> item {} ({}): {}",
|
||||||
|
harvestable_id, harvestable_name, item_id, item_name, e);
|
||||||
|
failed_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("✅ Processed {} harvestable icons ({} succeeded, {} failed)",
|
||||||
|
unique_harvestables.len(), processed_count, failed_count);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save teleporter data to database
|
||||||
|
fn save_teleporters(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
scene: &unity_parser::UnityScene,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use cursebreaker_parser::schema::world_teleporters;
|
||||||
|
|
||||||
|
// Clear existing teleporters
|
||||||
|
diesel::delete(world_teleporters::table).execute(conn)?;
|
||||||
|
|
||||||
|
let mut count = 0;
|
||||||
|
|
||||||
|
// Query all teleporters
|
||||||
|
scene.world
|
||||||
|
.query_all::<(&InteractableTeleporter, &unity_parser::WorldTransform, &unity_parser::GameObject)>()
|
||||||
|
.for_each(|(teleporter, transform, object)| {
|
||||||
|
let world_pos = transform.position();
|
||||||
|
|
||||||
|
// Get the tp_transform position if it exists
|
||||||
|
let (tp_x, tp_y) = if let Some(tp_entity) = teleporter.tp_transform {
|
||||||
|
if let Some(tp_transform) = scene.world.borrow::<unity_parser::WorldTransform>().get(tp_entity) {
|
||||||
|
let tp_pos = tp_transform.position();
|
||||||
|
(Some(tp_pos.x as f32), Some(tp_pos.z as f32))
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
info!(" 🗺️ Teleporter: \"{}\" at ({:.2}, {:.2}) -> ({:?}, {:?})",
|
||||||
|
object.name, world_pos.x, world_pos.z, tp_x, tp_y);
|
||||||
|
|
||||||
|
let _ = diesel::insert_into(world_teleporters::table)
|
||||||
|
.values((
|
||||||
|
world_teleporters::pos_x.eq(world_pos.x as f32),
|
||||||
|
world_teleporters::pos_y.eq(world_pos.z as f32),
|
||||||
|
world_teleporters::tp_x.eq(tp_x),
|
||||||
|
world_teleporters::tp_y.eq(tp_y),
|
||||||
|
))
|
||||||
|
.execute(conn);
|
||||||
|
|
||||||
|
count += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
info!("✅ Saved {} teleporters to database", count);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save workbench data to database
|
||||||
|
fn save_workbenches(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
scene: &unity_parser::UnityScene,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use cursebreaker_parser::schema::world_workbenches;
|
||||||
|
|
||||||
|
// Clear existing workbenches
|
||||||
|
diesel::delete(world_workbenches::table).execute(conn)?;
|
||||||
|
|
||||||
|
let mut count = 0;
|
||||||
|
|
||||||
|
// Query all workbenches
|
||||||
|
scene.world
|
||||||
|
.query_all::<(&InteractableWorkbench, &unity_parser::WorldTransform, &unity_parser::GameObject)>()
|
||||||
|
.for_each(|(workbench, transform, object)| {
|
||||||
|
let world_pos = transform.position();
|
||||||
|
|
||||||
|
info!(" 🔨 Workbench: \"{}\" (ID: {}) at ({:.2}, {:.2})",
|
||||||
|
object.name, workbench.workbench_id, world_pos.x, world_pos.z);
|
||||||
|
|
||||||
|
let _ = diesel::insert_into(world_workbenches::table)
|
||||||
|
.values((
|
||||||
|
world_workbenches::pos_x.eq(world_pos.x as f32),
|
||||||
|
world_workbenches::pos_y.eq(world_pos.z as f32),
|
||||||
|
world_workbenches::workbench_id.eq(workbench.workbench_id as i32),
|
||||||
|
))
|
||||||
|
.execute(conn);
|
||||||
|
|
||||||
|
count += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
info!("✅ Saved {} workbenches to database", count);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save loot spawner data to database
|
||||||
|
fn save_loot_spawners(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
scene: &unity_parser::UnityScene,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use cursebreaker_parser::schema::world_loot;
|
||||||
|
|
||||||
|
// Clear existing loot spawners
|
||||||
|
diesel::delete(world_loot::table).execute(conn)?;
|
||||||
|
|
||||||
|
let mut count = 0;
|
||||||
|
|
||||||
|
// Query all loot spawners
|
||||||
|
scene.world
|
||||||
|
.query_all::<(&LootSpawner, &unity_parser::WorldTransform, &unity_parser::GameObject)>()
|
||||||
|
.for_each(|(loot, transform, object)| {
|
||||||
|
let world_pos = transform.position();
|
||||||
|
|
||||||
|
info!(" 💰 Loot: \"{}\" (Item: {}, Amount: {}, Respawn: {}s) at ({:.2}, {:.2})",
|
||||||
|
object.name, loot.item_id, loot.amount, loot.respawn_time, world_pos.x, world_pos.z);
|
||||||
|
|
||||||
|
let _ = diesel::insert_into(world_loot::table)
|
||||||
|
.values((
|
||||||
|
world_loot::pos_x.eq(world_pos.x as f32),
|
||||||
|
world_loot::pos_y.eq(world_pos.z as f32),
|
||||||
|
world_loot::item_id.eq(loot.item_id as i32),
|
||||||
|
world_loot::amount.eq(loot.amount as i32),
|
||||||
|
world_loot::respawn_time.eq(loot.respawn_time as i32),
|
||||||
|
world_loot::visibility_checks.eq(&loot.visibility_checks),
|
||||||
|
))
|
||||||
|
.execute(conn);
|
||||||
|
|
||||||
|
count += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
info!("✅ Saved {} loot spawners to database", count);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save map icon data to database
|
||||||
|
fn save_map_icons(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
scene: &unity_parser::UnityScene,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use cursebreaker_parser::schema::world_map_icons;
|
||||||
|
|
||||||
|
// Clear existing map icons
|
||||||
|
diesel::delete(world_map_icons::table).execute(conn)?;
|
||||||
|
|
||||||
|
let mut count = 0;
|
||||||
|
|
||||||
|
// Query all map icons
|
||||||
|
scene.world
|
||||||
|
.query_all::<(&MapIcon, &unity_parser::WorldTransform, &unity_parser::GameObject)>()
|
||||||
|
.for_each(|(icon, transform, object)| {
|
||||||
|
let world_pos = transform.position();
|
||||||
|
|
||||||
|
info!(" 📍 MapIcon: \"{}\" (Type: {:?}, Text: \"{}\") at ({:.2}, {:.2})",
|
||||||
|
object.name, icon.icon_type, icon.text, world_pos.x, world_pos.z);
|
||||||
|
|
||||||
|
let _ = diesel::insert_into(world_map_icons::table)
|
||||||
|
.values((
|
||||||
|
world_map_icons::pos_x.eq(world_pos.x as f32),
|
||||||
|
world_map_icons::pos_y.eq(world_pos.z as f32),
|
||||||
|
world_map_icons::icon_type.eq(icon.icon_type as i32),
|
||||||
|
world_map_icons::icon_size.eq(icon.icon_size as i32),
|
||||||
|
world_map_icons::icon.eq(&icon.icon),
|
||||||
|
world_map_icons::text.eq(&icon.text),
|
||||||
|
world_map_icons::font_size.eq(icon.font_size as i32),
|
||||||
|
world_map_icons::hover_text.eq(&icon.hover_text),
|
||||||
|
))
|
||||||
|
.execute(conn);
|
||||||
|
|
||||||
|
count += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
info!("✅ Saved {} map icons to database", count);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save map name changer data to database
|
||||||
|
fn save_map_name_changers(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
scene: &unity_parser::UnityScene,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use cursebreaker_parser::schema::world_map_name_changers;
|
||||||
|
|
||||||
|
// Clear existing map name changers
|
||||||
|
diesel::delete(world_map_name_changers::table).execute(conn)?;
|
||||||
|
|
||||||
|
let mut count = 0;
|
||||||
|
|
||||||
|
// Query all map name changers
|
||||||
|
scene.world
|
||||||
|
.query_all::<(&MapNameChanger, &unity_parser::WorldTransform, &unity_parser::GameObject)>()
|
||||||
|
.for_each(|(changer, transform, object)| {
|
||||||
|
let world_pos = transform.position();
|
||||||
|
|
||||||
|
info!(" 🏷️ MapNameChanger: \"{}\" -> \"{}\" at ({:.2}, {:.2})",
|
||||||
|
object.name, changer.map_name, world_pos.x, world_pos.z);
|
||||||
|
|
||||||
|
let _ = diesel::insert_into(world_map_name_changers::table)
|
||||||
|
.values((
|
||||||
|
world_map_name_changers::pos_x.eq(world_pos.x as f32),
|
||||||
|
world_map_name_changers::pos_y.eq(world_pos.z as f32),
|
||||||
|
world_map_name_changers::map_name.eq(&changer.map_name),
|
||||||
|
))
|
||||||
|
.execute(conn);
|
||||||
|
|
||||||
|
count += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
info!("✅ Saved {} map name changers to database", count);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
132
cursebreaker-parser/src/bin/xml-parser.rs
Normal file
132
cursebreaker-parser/src/bin/xml-parser.rs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
//! XML Parser - Loads game data from XML files and populates the SQLite database
|
||||||
|
//!
|
||||||
|
//! This binary handles:
|
||||||
|
//! - Loading all game data from XML files
|
||||||
|
//! - Populating the SQLite database with the parsed data
|
||||||
|
//! - Generating statistics about the loaded data
|
||||||
|
|
||||||
|
use cursebreaker_parser::{ItemDatabase, HarvestableDatabase};
|
||||||
|
use log::{info, warn, LevelFilter};
|
||||||
|
use unity_parser::log::DedupLogger;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use diesel::sqlite::SqliteConnection;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let logger = DedupLogger::new();
|
||||||
|
log::set_boxed_logger(Box::new(logger))
|
||||||
|
.map(|()| log::set_max_level(LevelFilter::Trace))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
info!("🎮 Cursebreaker - XML Parser");
|
||||||
|
info!("📚 Loading game data from XML...");
|
||||||
|
|
||||||
|
let cb_assets_path = env::var("CB_ASSETS_PATH").unwrap_or_else(|_| "/home/connor/repos/CBAssets".to_string());
|
||||||
|
|
||||||
|
// Load items from XML
|
||||||
|
let items_path = format!("{}/Data/XMLs/Items/Items.xml", cb_assets_path);
|
||||||
|
let item_db = ItemDatabase::load_from_xml(items_path)?;
|
||||||
|
info!("✅ Loaded {} items", item_db.len());
|
||||||
|
|
||||||
|
// let npcs_path = format!("{}/Data/XMLs/Npcs/NPCInfo.xml", cb_assets_path);
|
||||||
|
// let npc_db = NpcDatabase::load_from_xml(npcs_path)?;
|
||||||
|
// info!("✅ Loaded {} NPCs", npc_db.len());
|
||||||
|
|
||||||
|
// let quests_path = format!("{}/Data/XMLs/Quests/Quests.xml", cb_assets_path);
|
||||||
|
// let quest_db = QuestDatabase::load_from_xml(quests_path)?;
|
||||||
|
// info!("✅ Loaded {} quests", quest_db.len());
|
||||||
|
|
||||||
|
let harvestables_path = format!("{}/Data/XMLs/Harvestables/HarvestableInfo.xml", cb_assets_path);
|
||||||
|
let harvestable_db = HarvestableDatabase::load_from_xml(harvestables_path)?;
|
||||||
|
info!("✅ Loaded {} harvestables", harvestable_db.len());
|
||||||
|
|
||||||
|
// let loot_path = format!("{}/Data/XMLs/Loot/Loot.xml", cb_assets_path);
|
||||||
|
// let loot_db = LootDatabase::load_from_xml(loot_path)?;
|
||||||
|
// info!("✅ Loaded {} loot tables", loot_db.len());
|
||||||
|
|
||||||
|
// let maps_path = format!("{}/Data/XMLs/Maps/Maps.xml", cb_assets_path);
|
||||||
|
// let map_db = MapDatabase::load_from_xml(maps_path)?;
|
||||||
|
// info!("✅ Loaded {} maps", map_db.len());
|
||||||
|
|
||||||
|
// let fast_travel_dir = format!("{}/Data/XMLs", cb_assets_path);
|
||||||
|
// let fast_travel_db = FastTravelDatabase::load_from_directory(fast_travel_dir)?;
|
||||||
|
// info!("✅ Loaded {} fast travel locations", fast_travel_db.len());
|
||||||
|
|
||||||
|
// let player_houses_path = format!("{}/Data/XMLs/PlayerHouses/PlayerHouses.xml", cb_assets_path);
|
||||||
|
// let player_house_db = PlayerHouseDatabase::load_from_xml(player_houses_path)?;
|
||||||
|
// info!("✅ Loaded {} player houses", player_house_db.len());
|
||||||
|
|
||||||
|
// let traits_path = format!("{}/Data/XMLs/Traits/Traits.xml", cb_assets_path);
|
||||||
|
// let trait_db = TraitDatabase::load_from_xml(traits_path)?;
|
||||||
|
// info!("✅ Loaded {} traits", trait_db.len());
|
||||||
|
|
||||||
|
// let shops_path = format!("{}/Data/XMLs/Shops/Shops.xml", cb_assets_path);
|
||||||
|
// let shop_db = ShopDatabase::load_from_xml(shops_path)?;
|
||||||
|
// info!("✅ Loaded {} shops", shop_db.len());
|
||||||
|
|
||||||
|
// Save to SQLite database
|
||||||
|
info!("\n💾 Saving game data to SQLite database...");
|
||||||
|
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "../cursebreaker.db".to_string());
|
||||||
|
let mut conn = SqliteConnection::establish(&database_url)?;
|
||||||
|
|
||||||
|
// Process and save items with icons
|
||||||
|
let icon_path = format!("{}/Data/Textures/ItemIcons", cb_assets_path);
|
||||||
|
info!("📸 Processing item icons from: {}", icon_path);
|
||||||
|
|
||||||
|
match item_db.save_to_db_with_images(&mut conn, &icon_path) {
|
||||||
|
Ok((items_count, images_count)) => {
|
||||||
|
info!("✅ Saved {} items to database", items_count);
|
||||||
|
info!("✅ Processed {} item icons (256px, 64px, 16px)", images_count);
|
||||||
|
}
|
||||||
|
Err(e) => warn!("⚠️ Failed to save items: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
// match npc_db.save_to_db(&mut conn) {
|
||||||
|
// Ok(count) => info!("✅ Saved {} NPCs to database", count),
|
||||||
|
// Err(e) => warn!("⚠️ Failed to save NPCs: {}", e),
|
||||||
|
// }
|
||||||
|
|
||||||
|
// match quest_db.save_to_db(&mut conn) {
|
||||||
|
// Ok(count) => info!("✅ Saved {} quests to database", count),
|
||||||
|
// Err(e) => warn!("⚠️ Failed to save quests: {}", e),
|
||||||
|
// }
|
||||||
|
|
||||||
|
match harvestable_db.save_to_db(&mut conn) {
|
||||||
|
Ok(count) => info!("✅ Saved {} harvestables to database", count),
|
||||||
|
Err(e) => warn!("⚠️ Failed to save harvestables: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
// match loot_db.save_to_db(&mut conn) {
|
||||||
|
// Ok(count) => info!("✅ Saved {} loot tables to database", count),
|
||||||
|
// Err(e) => warn!("⚠️ Failed to save loot tables: {}", e),
|
||||||
|
// }
|
||||||
|
|
||||||
|
// match map_db.save_to_db(&mut conn) {
|
||||||
|
// Ok(count) => info!("✅ Saved {} maps to database", count),
|
||||||
|
// Err(e) => warn!("⚠️ Failed to save maps: {}", e),
|
||||||
|
// }
|
||||||
|
|
||||||
|
// match fast_travel_db.save_to_db(&mut conn) {
|
||||||
|
// Ok(count) => info!("✅ Saved {} fast travel locations to database", count),
|
||||||
|
// Err(e) => warn!("⚠️ Failed to save fast travel locations: {}", e),
|
||||||
|
// }
|
||||||
|
|
||||||
|
// match player_house_db.save_to_db(&mut conn) {
|
||||||
|
// Ok(count) => info!("✅ Saved {} player houses to database", count),
|
||||||
|
// Err(e) => warn!("⚠️ Failed to save player houses: {}", e),
|
||||||
|
// }
|
||||||
|
|
||||||
|
// match trait_db.save_to_db(&mut conn) {
|
||||||
|
// Ok(count) => info!("✅ Saved {} traits to database", count),
|
||||||
|
// Err(e) => warn!("⚠️ Failed to save traits: {}", e),
|
||||||
|
// }
|
||||||
|
|
||||||
|
// match shop_db.save_to_db(&mut conn) {
|
||||||
|
// Ok(count) => info!("✅ Saved {} shops to database", count),
|
||||||
|
// Err(e) => warn!("⚠️ Failed to save shops: {}", e),
|
||||||
|
// }
|
||||||
|
|
||||||
|
log::logger().flush();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -62,27 +62,21 @@ impl HarvestableDatabase {
|
|||||||
|
|
||||||
/// Get harvestables by skill
|
/// Get harvestables by skill
|
||||||
pub fn get_by_skill(&self, skill: &str) -> Vec<&Harvestable> {
|
pub fn get_by_skill(&self, skill: &str) -> Vec<&Harvestable> {
|
||||||
|
use crate::types::SkillType;
|
||||||
|
let skill_type = skill.parse::<SkillType>().unwrap_or(SkillType::None);
|
||||||
self.harvestables
|
self.harvestables
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|h| {
|
.filter(|h| h.skill == skill_type)
|
||||||
h.skill
|
|
||||||
.as_ref()
|
|
||||||
.map(|s| s.eq_ignore_ascii_case(skill))
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get harvestables that require a specific tool
|
/// Get harvestables that require a specific tool
|
||||||
pub fn get_by_tool(&self, tool: &str) -> Vec<&Harvestable> {
|
pub fn get_by_tool(&self, tool: &str) -> Vec<&Harvestable> {
|
||||||
|
use crate::types::Tool;
|
||||||
|
let tool_type = tool.parse::<Tool>().unwrap_or(Tool::None);
|
||||||
self.harvestables
|
self.harvestables
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|h| {
|
.filter(|h| h.tool == tool_type)
|
||||||
h.tool
|
|
||||||
.as_ref()
|
|
||||||
.map(|t| t.eq_ignore_ascii_case(tool))
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,11 +100,7 @@ impl HarvestableDatabase {
|
|||||||
pub fn get_by_level_range(&self, min_level: i32, max_level: i32) -> Vec<&Harvestable> {
|
pub fn get_by_level_range(&self, min_level: i32, max_level: i32) -> Vec<&Harvestable> {
|
||||||
self.harvestables
|
self.harvestables
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|h| {
|
.filter(|h| h.level >= min_level && h.level <= max_level)
|
||||||
h.level
|
|
||||||
.map(|l| l >= min_level && l <= max_level)
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,38 +116,136 @@ impl HarvestableDatabase {
|
|||||||
|
|
||||||
/// Prepare harvestables for SQL insertion (deprecated - use save_to_db instead)
|
/// Prepare harvestables for SQL insertion (deprecated - use save_to_db instead)
|
||||||
#[deprecated(note = "Use save_to_db() to save directly to SQLite database")]
|
#[deprecated(note = "Use save_to_db() to save directly to SQLite database")]
|
||||||
pub fn prepare_for_sql(&self) -> Vec<(i32, String, String)> {
|
#[allow(deprecated)]
|
||||||
|
pub fn prepare_for_sql(&self) -> Vec<(i32, String, String, String, i32, String, String, i32, i32, i32, i32, i32)> {
|
||||||
|
use crate::types::{SkillType, Tool};
|
||||||
|
|
||||||
self.harvestables
|
self.harvestables
|
||||||
.iter()
|
.iter()
|
||||||
.map(|harvestable| {
|
.map(|harvestable| {
|
||||||
let json = serde_json::to_string(harvestable).unwrap_or_else(|_| "{}".to_string());
|
let skill_str = match harvestable.skill {
|
||||||
(harvestable.typeid, harvestable.name.clone(), json)
|
SkillType::None => "none",
|
||||||
|
SkillType::Swordsmanship => "swordsmanship",
|
||||||
|
SkillType::Archery => "archery",
|
||||||
|
SkillType::Magic => "magic",
|
||||||
|
SkillType::Defence => "defence",
|
||||||
|
SkillType::Mining => "mining",
|
||||||
|
SkillType::Woodcutting => "woodcutting",
|
||||||
|
SkillType::Fishing => "fishing",
|
||||||
|
SkillType::Cooking => "cooking",
|
||||||
|
SkillType::Carpentry => "carpentry",
|
||||||
|
SkillType::Blacksmithy => "blacksmithy",
|
||||||
|
SkillType::Tailoring => "tailoring",
|
||||||
|
SkillType::Alchemy => "alchemy",
|
||||||
|
}.to_string();
|
||||||
|
|
||||||
|
let tool_str = match harvestable.tool {
|
||||||
|
Tool::None => "none",
|
||||||
|
Tool::Pickaxe => "pickaxe",
|
||||||
|
Tool::Hatchet => "hatchet",
|
||||||
|
Tool::Scythe => "scythe",
|
||||||
|
Tool::Hammer => "hammer",
|
||||||
|
Tool::Shears => "shears",
|
||||||
|
Tool::FishingRod => "fishingrod",
|
||||||
|
}.to_string();
|
||||||
|
|
||||||
|
(
|
||||||
|
harvestable.typeid,
|
||||||
|
harvestable.name.clone(),
|
||||||
|
harvestable.desc.clone(),
|
||||||
|
harvestable.comment.clone(),
|
||||||
|
harvestable.level,
|
||||||
|
skill_str,
|
||||||
|
tool_str,
|
||||||
|
harvestable.min_health,
|
||||||
|
harvestable.max_health,
|
||||||
|
harvestable.harvesttime,
|
||||||
|
harvestable.hittime,
|
||||||
|
harvestable.respawntime,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save all harvestables to SQLite database
|
/// Save all harvestables to SQLite database
|
||||||
pub fn save_to_db(&self, conn: &mut SqliteConnection) -> Result<usize, diesel::result::Error> {
|
pub fn save_to_db(&self, conn: &mut SqliteConnection) -> Result<usize, diesel::result::Error> {
|
||||||
use crate::schema::harvestables;
|
use crate::schema::{harvestables, harvestable_drops};
|
||||||
|
use crate::types::{SkillType, Tool};
|
||||||
|
|
||||||
let records: Vec<_> = self
|
// Clear existing data
|
||||||
.harvestables
|
diesel::delete(harvestable_drops::table).execute(conn)?;
|
||||||
.iter()
|
diesel::delete(harvestables::table).execute(conn)?;
|
||||||
.map(|harvestable| {
|
|
||||||
let json = serde_json::to_string(harvestable).unwrap_or_else(|_| "{}".to_string());
|
|
||||||
(
|
|
||||||
harvestables::id.eq(harvestable.typeid),
|
|
||||||
harvestables::name.eq(&harvestable.name),
|
|
||||||
harvestables::data.eq(json),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
for record in records {
|
for harvestable in &self.harvestables {
|
||||||
|
// Convert enums to strings for database storage
|
||||||
|
let skill_str = match harvestable.skill {
|
||||||
|
SkillType::None => "none",
|
||||||
|
SkillType::Swordsmanship => "swordsmanship",
|
||||||
|
SkillType::Archery => "archery",
|
||||||
|
SkillType::Magic => "magic",
|
||||||
|
SkillType::Defence => "defence",
|
||||||
|
SkillType::Mining => "mining",
|
||||||
|
SkillType::Woodcutting => "woodcutting",
|
||||||
|
SkillType::Fishing => "fishing",
|
||||||
|
SkillType::Cooking => "cooking",
|
||||||
|
SkillType::Carpentry => "carpentry",
|
||||||
|
SkillType::Blacksmithy => "blacksmithy",
|
||||||
|
SkillType::Tailoring => "tailoring",
|
||||||
|
SkillType::Alchemy => "alchemy",
|
||||||
|
};
|
||||||
|
|
||||||
|
let tool_str = match harvestable.tool {
|
||||||
|
Tool::None => "none",
|
||||||
|
Tool::Pickaxe => "pickaxe",
|
||||||
|
Tool::Hatchet => "hatchet",
|
||||||
|
Tool::Scythe => "scythe",
|
||||||
|
Tool::Hammer => "hammer",
|
||||||
|
Tool::Shears => "shears",
|
||||||
|
Tool::FishingRod => "fishingrod",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Insert harvestable
|
||||||
diesel::insert_into(harvestables::table)
|
diesel::insert_into(harvestables::table)
|
||||||
.values(&record)
|
.values((
|
||||||
|
harvestables::id.eq(harvestable.typeid),
|
||||||
|
harvestables::name.eq(&harvestable.name),
|
||||||
|
harvestables::description.eq(&harvestable.desc),
|
||||||
|
harvestables::comment.eq(&harvestable.comment),
|
||||||
|
harvestables::level.eq(harvestable.level),
|
||||||
|
harvestables::skill.eq(skill_str),
|
||||||
|
harvestables::tool.eq(tool_str),
|
||||||
|
harvestables::min_health.eq(harvestable.min_health),
|
||||||
|
harvestables::max_health.eq(harvestable.max_health),
|
||||||
|
harvestables::harvesttime.eq(harvestable.harvesttime),
|
||||||
|
harvestables::hittime.eq(harvestable.hittime),
|
||||||
|
harvestables::respawntime.eq(harvestable.respawntime),
|
||||||
|
))
|
||||||
.execute(conn)?;
|
.execute(conn)?;
|
||||||
|
|
||||||
|
// Insert drops
|
||||||
|
for drop in &harvestable.drops {
|
||||||
|
// Try to insert, but skip if foreign key constraint fails (item doesn't exist)
|
||||||
|
let insert_result = diesel::insert_into(harvestable_drops::table)
|
||||||
|
.values((
|
||||||
|
harvestable_drops::harvestable_id.eq(harvestable.typeid),
|
||||||
|
harvestable_drops::item_id.eq(drop.id),
|
||||||
|
harvestable_drops::minamount.eq(drop.minamount),
|
||||||
|
harvestable_drops::maxamount.eq(drop.maxamount),
|
||||||
|
harvestable_drops::droprate.eq(drop.droprate),
|
||||||
|
harvestable_drops::droprateboost.eq(drop.droprateboost),
|
||||||
|
harvestable_drops::amountboost.eq(drop.amountboost),
|
||||||
|
harvestable_drops::comment.eq(&drop.comment),
|
||||||
|
))
|
||||||
|
.execute(conn);
|
||||||
|
|
||||||
|
// Log warning if insert failed but continue
|
||||||
|
if let Err(e) = insert_result {
|
||||||
|
eprintln!("Warning: Failed to insert drop for harvestable {} (item {}): {}",
|
||||||
|
harvestable.typeid, drop.id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,22 +254,91 @@ impl HarvestableDatabase {
|
|||||||
|
|
||||||
/// Load all harvestables from SQLite database
|
/// Load all harvestables from SQLite database
|
||||||
pub fn load_from_db(conn: &mut SqliteConnection) -> Result<Self, diesel::result::Error> {
|
pub fn load_from_db(conn: &mut SqliteConnection) -> Result<Self, diesel::result::Error> {
|
||||||
use crate::schema::harvestables::dsl::*;
|
use crate::schema::{harvestables, harvestable_drops};
|
||||||
|
use crate::types::{Harvestable, HarvestableDrop, SkillType, Tool};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
#[derive(Queryable)]
|
#[derive(Queryable)]
|
||||||
struct HarvestableRecord {
|
struct HarvestableRecord {
|
||||||
id: Option<i32>,
|
id: i32,
|
||||||
name: String,
|
name: String,
|
||||||
data: String,
|
description: String,
|
||||||
|
comment: String,
|
||||||
|
level: i32,
|
||||||
|
skill: String,
|
||||||
|
tool: String,
|
||||||
|
min_health: i32,
|
||||||
|
max_health: i32,
|
||||||
|
harvesttime: i32,
|
||||||
|
hittime: i32,
|
||||||
|
respawntime: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
let records = harvestables.load::<HarvestableRecord>(conn)?;
|
#[derive(Queryable)]
|
||||||
|
struct HarvestableDropRecord {
|
||||||
|
id: Option<i32>,
|
||||||
|
harvestable_id: i32,
|
||||||
|
item_id: i32,
|
||||||
|
minamount: i32,
|
||||||
|
maxamount: i32,
|
||||||
|
droprate: i32,
|
||||||
|
droprateboost: i32,
|
||||||
|
amountboost: i32,
|
||||||
|
comment: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let harv_records = harvestables::table.load::<HarvestableRecord>(conn)?;
|
||||||
|
let drop_records = harvestable_drops::table.load::<HarvestableDropRecord>(conn)?;
|
||||||
|
|
||||||
let mut loaded_harvestables = Vec::new();
|
let mut loaded_harvestables = Vec::new();
|
||||||
for record in records {
|
for record in harv_records {
|
||||||
if let Ok(harvestable) = serde_json::from_str::<Harvestable>(&record.data) {
|
let mut harvestable = Harvestable {
|
||||||
loaded_harvestables.push(harvestable);
|
typeid: record.id,
|
||||||
|
name: record.name,
|
||||||
|
actionname: String::new(),
|
||||||
|
desc: record.description,
|
||||||
|
comment: record.comment,
|
||||||
|
level: record.level,
|
||||||
|
skill: record.skill.parse().unwrap_or(SkillType::None),
|
||||||
|
tool: record.tool.parse().unwrap_or(Tool::None),
|
||||||
|
min_health: record.min_health,
|
||||||
|
max_health: record.max_health,
|
||||||
|
harvesttime: record.harvesttime,
|
||||||
|
hittime: record.hittime,
|
||||||
|
respawntime: record.respawntime,
|
||||||
|
harvestsfx: String::new(),
|
||||||
|
endsfx: String::new(),
|
||||||
|
receiveitemsfx: String::new(),
|
||||||
|
animation: String::new(),
|
||||||
|
takehitanimation: String::new(),
|
||||||
|
endgfx: String::new(),
|
||||||
|
tree: false,
|
||||||
|
hidemilestone: false,
|
||||||
|
nohighlight: false,
|
||||||
|
hideminimap: false,
|
||||||
|
noleftclickinteract: false,
|
||||||
|
interactdistance: String::new(),
|
||||||
|
drops: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add drops for this harvestable
|
||||||
|
for drop_rec in &drop_records {
|
||||||
|
if drop_rec.harvestable_id == record.id {
|
||||||
|
harvestable.drops.push(HarvestableDrop {
|
||||||
|
id: drop_rec.item_id,
|
||||||
|
minamount: drop_rec.minamount,
|
||||||
|
maxamount: drop_rec.maxamount,
|
||||||
|
droprate: drop_rec.droprate,
|
||||||
|
droprateboost: drop_rec.droprateboost,
|
||||||
|
amountboost: drop_rec.amountboost,
|
||||||
|
checks: String::new(),
|
||||||
|
comment: drop_rec.comment.clone(),
|
||||||
|
dontconsumehealth: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loaded_harvestables.push(harvestable);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut db = Self::new();
|
let mut db = Self::new();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::image_processor::ImageProcessor;
|
||||||
use crate::item_loader::{
|
use crate::item_loader::{
|
||||||
calculate_prices, generate_banknotes, generate_exceptional_items, load_items_from_directory,
|
calculate_prices, generate_banknotes, generate_exceptional_items, load_items_from_directory,
|
||||||
};
|
};
|
||||||
@@ -6,7 +7,7 @@ use crate::xml_parser::{parse_items_xml, XmlParseError};
|
|||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use diesel::sqlite::SqliteConnection;
|
use diesel::sqlite::SqliteConnection;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// A database for managing game items loaded from XML files
|
/// A database for managing game items loaded from XML files
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -215,30 +216,324 @@ impl ItemDatabase {
|
|||||||
|
|
||||||
/// Save all items to SQLite database
|
/// Save all items to SQLite database
|
||||||
pub fn save_to_db(&self, conn: &mut SqliteConnection) -> Result<usize, diesel::result::Error> {
|
pub fn save_to_db(&self, conn: &mut SqliteConnection) -> Result<usize, diesel::result::Error> {
|
||||||
use crate::schema::items;
|
use crate::schema::{items, crafting_recipes, crafting_recipe_items};
|
||||||
|
use diesel::replace_into;
|
||||||
|
|
||||||
let records: Vec<_> = self
|
conn.transaction::<_, diesel::result::Error, _>(|conn| {
|
||||||
.items
|
let mut count = 0;
|
||||||
.iter()
|
|
||||||
.map(|item| {
|
for item in &self.items {
|
||||||
let json = serde_json::to_string(item).unwrap_or_else(|_| "{}".to_string());
|
let json = serde_json::to_string(item).unwrap_or_else(|_| "{}".to_string());
|
||||||
(
|
|
||||||
items::id.eq(item.type_id),
|
|
||||||
items::name.eq(&item.item_name),
|
|
||||||
items::data.eq(json),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut count = 0;
|
// Insert/replace item with all columns
|
||||||
for record in records {
|
replace_into(items::table)
|
||||||
diesel::insert_into(items::table)
|
.values((
|
||||||
.values(&record)
|
items::id.eq(item.type_id),
|
||||||
.execute(conn)?;
|
items::name.eq(&item.item_name),
|
||||||
count += 1;
|
items::data.eq(json),
|
||||||
|
items::item_type.eq(item.item_type.to_string()),
|
||||||
|
items::level.eq(item.level),
|
||||||
|
items::price.eq(item.price),
|
||||||
|
items::max_stack.eq(item.max_stack),
|
||||||
|
items::storage_size.eq(item.storage_size),
|
||||||
|
items::skill.eq(match item.skill {
|
||||||
|
crate::types::SkillType::None => "none",
|
||||||
|
crate::types::SkillType::Swordsmanship => "swordsmanship",
|
||||||
|
crate::types::SkillType::Archery => "archery",
|
||||||
|
crate::types::SkillType::Magic => "magic",
|
||||||
|
crate::types::SkillType::Defence => "defence",
|
||||||
|
crate::types::SkillType::Mining => "mining",
|
||||||
|
crate::types::SkillType::Woodcutting => "woodcutting",
|
||||||
|
crate::types::SkillType::Fishing => "fishing",
|
||||||
|
crate::types::SkillType::Cooking => "cooking",
|
||||||
|
crate::types::SkillType::Carpentry => "carpentry",
|
||||||
|
crate::types::SkillType::Blacksmithy => "blacksmithy",
|
||||||
|
crate::types::SkillType::Tailoring => "tailoring",
|
||||||
|
crate::types::SkillType::Alchemy => "alchemy",
|
||||||
|
}),
|
||||||
|
items::tool.eq(match item.tool {
|
||||||
|
crate::types::Tool::None => "none",
|
||||||
|
crate::types::Tool::Pickaxe => "pickaxe",
|
||||||
|
crate::types::Tool::Hatchet => "hatchet",
|
||||||
|
crate::types::Tool::Scythe => "scythe",
|
||||||
|
crate::types::Tool::Hammer => "hammer",
|
||||||
|
crate::types::Tool::Shears => "shears",
|
||||||
|
crate::types::Tool::FishingRod => "fishingrod",
|
||||||
|
}),
|
||||||
|
items::description.eq(&item.description),
|
||||||
|
items::two_handed.eq(item.two_handed as i32),
|
||||||
|
items::undroppable.eq(item.undroppable as i32),
|
||||||
|
items::undroppable_on_death.eq(item.undroppable_on_death as i32),
|
||||||
|
items::unequip_destroy.eq(item.unequip_destroy as i32),
|
||||||
|
items::generate_icon.eq(item.generate_icon as i32),
|
||||||
|
items::hide_milestone.eq(item.hide_milestone as i32),
|
||||||
|
items::cannot_craft_exceptional.eq(item.cannot_craft_exceptional as i32),
|
||||||
|
items::storage_all_items.eq(item.storage_all_items as i32),
|
||||||
|
items::ability_id.eq(item.ability_id),
|
||||||
|
items::special_ability.eq(item.special_ability),
|
||||||
|
items::learn_ability_id.eq(item.learn_ability_id),
|
||||||
|
items::book_id.eq(item.book_id),
|
||||||
|
items::swap_item.eq(item.swap_item),
|
||||||
|
))
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
// Save crafting recipes for this item
|
||||||
|
for recipe in &item.crafting_recipes {
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
// Insert recipe
|
||||||
|
diesel::insert_into(crafting_recipes::table)
|
||||||
|
.values((
|
||||||
|
crafting_recipes::product_item_id.eq(item.type_id),
|
||||||
|
crafting_recipes::skill.eq(match recipe.skill {
|
||||||
|
crate::types::SkillType::None => "none",
|
||||||
|
crate::types::SkillType::Swordsmanship => "swordsmanship",
|
||||||
|
crate::types::SkillType::Archery => "archery",
|
||||||
|
crate::types::SkillType::Magic => "magic",
|
||||||
|
crate::types::SkillType::Defence => "defence",
|
||||||
|
crate::types::SkillType::Mining => "mining",
|
||||||
|
crate::types::SkillType::Woodcutting => "woodcutting",
|
||||||
|
crate::types::SkillType::Fishing => "fishing",
|
||||||
|
crate::types::SkillType::Cooking => "cooking",
|
||||||
|
crate::types::SkillType::Carpentry => "carpentry",
|
||||||
|
crate::types::SkillType::Blacksmithy => "blacksmithy",
|
||||||
|
crate::types::SkillType::Tailoring => "tailoring",
|
||||||
|
crate::types::SkillType::Alchemy => "alchemy",
|
||||||
|
}),
|
||||||
|
crafting_recipes::level.eq(recipe.level),
|
||||||
|
crafting_recipes::workbench_id.eq(recipe.workbench_id),
|
||||||
|
crafting_recipes::xp.eq(recipe.xp),
|
||||||
|
crafting_recipes::unlocked_by_default.eq(recipe.unlocked_by_default as i32),
|
||||||
|
crafting_recipes::checks.eq(recipe.checks.as_ref()),
|
||||||
|
))
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
// Get the recipe_id we just inserted
|
||||||
|
let recipe_id: i32 = diesel::select(diesel::dsl::sql::<diesel::sql_types::Integer>(
|
||||||
|
"last_insert_rowid()"
|
||||||
|
))
|
||||||
|
.get_result(conn)?;
|
||||||
|
|
||||||
|
// Insert recipe items (ingredients)
|
||||||
|
for ingredient in &recipe.items {
|
||||||
|
diesel::insert_into(crafting_recipe_items::table)
|
||||||
|
.values((
|
||||||
|
crafting_recipe_items::recipe_id.eq(recipe_id),
|
||||||
|
crafting_recipe_items::item_id.eq(ingredient.item_id),
|
||||||
|
crafting_recipe_items::amount.eq(ingredient.amount),
|
||||||
|
))
|
||||||
|
.execute(conn)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(count)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save all items to SQLite database with icon processing
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `conn` - Database connection
|
||||||
|
/// * `icon_path` - Path to the ItemIcons directory (e.g., "CBAssets/Data/Textures/ItemIcons")
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// Tuple of (items_saved, images_processed)
|
||||||
|
pub fn save_to_db_with_images<P: AsRef<Path>>(
|
||||||
|
&self,
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
icon_path: P,
|
||||||
|
) -> Result<(usize, usize), diesel::result::Error> {
|
||||||
|
use crate::schema::items;
|
||||||
|
use diesel::replace_into;
|
||||||
|
|
||||||
|
let icon_base_path = icon_path.as_ref();
|
||||||
|
let processor = ImageProcessor::new(85.0); // 85% WebP quality
|
||||||
|
let mut images_processed = 0;
|
||||||
|
|
||||||
|
conn.transaction::<_, diesel::result::Error, _>(|conn| {
|
||||||
|
let mut count = 0;
|
||||||
|
|
||||||
|
for item in &self.items {
|
||||||
|
let json = serde_json::to_string(item).unwrap_or_else(|_| "{}".to_string());
|
||||||
|
|
||||||
|
// Process item icon if it exists
|
||||||
|
let (icon_large, icon_medium, icon_small) =
|
||||||
|
Self::process_item_icon(&processor, icon_base_path, item.type_id);
|
||||||
|
|
||||||
|
if icon_large.is_some() {
|
||||||
|
images_processed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert/replace item with all columns including images
|
||||||
|
replace_into(items::table)
|
||||||
|
.values((
|
||||||
|
items::id.eq(item.type_id),
|
||||||
|
items::name.eq(&item.item_name),
|
||||||
|
items::data.eq(json),
|
||||||
|
items::item_type.eq(item.item_type.to_string()),
|
||||||
|
items::level.eq(item.level),
|
||||||
|
items::price.eq(item.price),
|
||||||
|
items::max_stack.eq(item.max_stack),
|
||||||
|
items::storage_size.eq(item.storage_size),
|
||||||
|
items::skill.eq(match item.skill {
|
||||||
|
crate::types::SkillType::None => "none",
|
||||||
|
crate::types::SkillType::Swordsmanship => "swordsmanship",
|
||||||
|
crate::types::SkillType::Archery => "archery",
|
||||||
|
crate::types::SkillType::Magic => "magic",
|
||||||
|
crate::types::SkillType::Defence => "defence",
|
||||||
|
crate::types::SkillType::Mining => "mining",
|
||||||
|
crate::types::SkillType::Woodcutting => "woodcutting",
|
||||||
|
crate::types::SkillType::Fishing => "fishing",
|
||||||
|
crate::types::SkillType::Cooking => "cooking",
|
||||||
|
crate::types::SkillType::Carpentry => "carpentry",
|
||||||
|
crate::types::SkillType::Blacksmithy => "blacksmithy",
|
||||||
|
crate::types::SkillType::Tailoring => "tailoring",
|
||||||
|
crate::types::SkillType::Alchemy => "alchemy",
|
||||||
|
}),
|
||||||
|
items::tool.eq(match item.tool {
|
||||||
|
crate::types::Tool::None => "none",
|
||||||
|
crate::types::Tool::Pickaxe => "pickaxe",
|
||||||
|
crate::types::Tool::Hatchet => "hatchet",
|
||||||
|
crate::types::Tool::Scythe => "scythe",
|
||||||
|
crate::types::Tool::Hammer => "hammer",
|
||||||
|
crate::types::Tool::Shears => "shears",
|
||||||
|
crate::types::Tool::FishingRod => "fishingrod",
|
||||||
|
}),
|
||||||
|
items::description.eq(&item.description),
|
||||||
|
items::two_handed.eq(item.two_handed as i32),
|
||||||
|
items::undroppable.eq(item.undroppable as i32),
|
||||||
|
items::undroppable_on_death.eq(item.undroppable_on_death as i32),
|
||||||
|
items::unequip_destroy.eq(item.unequip_destroy as i32),
|
||||||
|
items::generate_icon.eq(item.generate_icon as i32),
|
||||||
|
items::hide_milestone.eq(item.hide_milestone as i32),
|
||||||
|
items::cannot_craft_exceptional.eq(item.cannot_craft_exceptional as i32),
|
||||||
|
items::storage_all_items.eq(item.storage_all_items as i32),
|
||||||
|
items::ability_id.eq(item.ability_id),
|
||||||
|
items::special_ability.eq(item.special_ability),
|
||||||
|
items::learn_ability_id.eq(item.learn_ability_id),
|
||||||
|
items::book_id.eq(item.book_id),
|
||||||
|
items::swap_item.eq(item.swap_item),
|
||||||
|
items::icon_large.eq(icon_large.as_ref()),
|
||||||
|
items::icon_medium.eq(icon_medium.as_ref()),
|
||||||
|
items::icon_small.eq(icon_small.as_ref()),
|
||||||
|
))
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
// Save crafting recipes for this item (same as before)
|
||||||
|
for recipe in &item.crafting_recipes {
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
diesel::insert_into(crate::schema::crafting_recipes::table)
|
||||||
|
.values((
|
||||||
|
crate::schema::crafting_recipes::product_item_id.eq(item.type_id),
|
||||||
|
crate::schema::crafting_recipes::skill.eq(match recipe.skill {
|
||||||
|
crate::types::SkillType::None => "none",
|
||||||
|
crate::types::SkillType::Swordsmanship => "swordsmanship",
|
||||||
|
crate::types::SkillType::Archery => "archery",
|
||||||
|
crate::types::SkillType::Magic => "magic",
|
||||||
|
crate::types::SkillType::Defence => "defence",
|
||||||
|
crate::types::SkillType::Mining => "mining",
|
||||||
|
crate::types::SkillType::Woodcutting => "woodcutting",
|
||||||
|
crate::types::SkillType::Fishing => "fishing",
|
||||||
|
crate::types::SkillType::Cooking => "cooking",
|
||||||
|
crate::types::SkillType::Carpentry => "carpentry",
|
||||||
|
crate::types::SkillType::Blacksmithy => "blacksmithy",
|
||||||
|
crate::types::SkillType::Tailoring => "tailoring",
|
||||||
|
crate::types::SkillType::Alchemy => "alchemy",
|
||||||
|
}),
|
||||||
|
crate::schema::crafting_recipes::level.eq(recipe.level),
|
||||||
|
crate::schema::crafting_recipes::workbench_id.eq(recipe.workbench_id),
|
||||||
|
crate::schema::crafting_recipes::xp.eq(recipe.xp),
|
||||||
|
crate::schema::crafting_recipes::unlocked_by_default.eq(recipe.unlocked_by_default as i32),
|
||||||
|
crate::schema::crafting_recipes::checks.eq(recipe.checks.as_ref()),
|
||||||
|
))
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
let recipe_id: i32 = diesel::select(diesel::dsl::sql::<diesel::sql_types::Integer>(
|
||||||
|
"last_insert_rowid()"
|
||||||
|
))
|
||||||
|
.get_result(conn)?;
|
||||||
|
|
||||||
|
for ingredient in &recipe.items {
|
||||||
|
diesel::insert_into(crate::schema::crafting_recipe_items::table)
|
||||||
|
.values((
|
||||||
|
crate::schema::crafting_recipe_items::recipe_id.eq(recipe_id),
|
||||||
|
crate::schema::crafting_recipe_items::item_id.eq(ingredient.item_id),
|
||||||
|
crate::schema::crafting_recipe_items::amount.eq(ingredient.amount),
|
||||||
|
))
|
||||||
|
.execute(conn)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save item stats
|
||||||
|
for stat in &item.stats {
|
||||||
|
let stat_type_str = match stat.stat_type {
|
||||||
|
crate::types::StatType::None => "none",
|
||||||
|
crate::types::StatType::Health => "health",
|
||||||
|
crate::types::StatType::Mana => "mana",
|
||||||
|
crate::types::StatType::HealthRegen => "health_regen",
|
||||||
|
crate::types::StatType::ManaRegen => "mana_regen",
|
||||||
|
crate::types::StatType::DamagePhysical => "damage_physical",
|
||||||
|
crate::types::StatType::DamageMagical => "damage_magical",
|
||||||
|
crate::types::StatType::DamageRanged => "damage_ranged",
|
||||||
|
crate::types::StatType::AccuracyPhysical => "accuracy_physical",
|
||||||
|
crate::types::StatType::AccuracyMagical => "accuracy_magical",
|
||||||
|
crate::types::StatType::AccuracyRanged => "accuracy_ranged",
|
||||||
|
crate::types::StatType::ResistancePhysical => "resistance_physical",
|
||||||
|
crate::types::StatType::ResistanceMagical => "resistance_magical",
|
||||||
|
crate::types::StatType::ResistanceRanged => "resistance_ranged",
|
||||||
|
crate::types::StatType::Critical => "critical",
|
||||||
|
crate::types::StatType::Healing => "healing",
|
||||||
|
crate::types::StatType::MovementSpeed => "movement_speed",
|
||||||
|
crate::types::StatType::DamageVsBeasts => "damage_vs_beasts",
|
||||||
|
crate::types::StatType::DamageVsUndead => "damage_vs_undead",
|
||||||
|
crate::types::StatType::CritterSlaying => "critter_slaying",
|
||||||
|
};
|
||||||
|
|
||||||
|
diesel::insert_into(crate::schema::item_stats::table)
|
||||||
|
.values((
|
||||||
|
crate::schema::item_stats::item_id.eq(item.type_id),
|
||||||
|
crate::schema::item_stats::stat_type.eq(stat_type_str),
|
||||||
|
crate::schema::item_stats::value.eq(stat.value),
|
||||||
|
))
|
||||||
|
.execute(conn)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((count, images_processed))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to process a single item icon
|
||||||
|
/// Returns (large, medium, small) WebP blobs
|
||||||
|
fn process_item_icon(
|
||||||
|
processor: &ImageProcessor,
|
||||||
|
icon_base_path: &Path,
|
||||||
|
item_id: i32,
|
||||||
|
) -> (Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>) {
|
||||||
|
let icon_file = icon_base_path.join(format!("{}.png", item_id));
|
||||||
|
|
||||||
|
if !icon_file.exists() {
|
||||||
|
return (None, None, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(count)
|
// Process image at 3 sizes: 256, 64, 16
|
||||||
|
match processor.process_image(&icon_file, &[256, 64, 16], None, None) {
|
||||||
|
Ok(processed) => (
|
||||||
|
processed.get(256).cloned(),
|
||||||
|
processed.get(64).cloned(),
|
||||||
|
processed.get(16).cloned(),
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to process icon for item {}: {}", item_id, e);
|
||||||
|
(None, None, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load all items from SQLite database
|
/// Load all items from SQLite database
|
||||||
@@ -246,16 +541,42 @@ impl ItemDatabase {
|
|||||||
use crate::schema::items::dsl::*;
|
use crate::schema::items::dsl::*;
|
||||||
|
|
||||||
#[derive(Queryable)]
|
#[derive(Queryable)]
|
||||||
|
#[allow(dead_code)]
|
||||||
struct ItemRecord {
|
struct ItemRecord {
|
||||||
id: Option<i32>,
|
id: Option<i32>,
|
||||||
name: String,
|
name: String,
|
||||||
data: String,
|
data: String,
|
||||||
|
item_type: String,
|
||||||
|
level: i32,
|
||||||
|
price: i32,
|
||||||
|
max_stack: i32,
|
||||||
|
storage_size: i32,
|
||||||
|
skill: String,
|
||||||
|
tool: String,
|
||||||
|
description: String,
|
||||||
|
two_handed: i32,
|
||||||
|
undroppable: i32,
|
||||||
|
undroppable_on_death: i32,
|
||||||
|
unequip_destroy: i32,
|
||||||
|
generate_icon: i32,
|
||||||
|
hide_milestone: i32,
|
||||||
|
cannot_craft_exceptional: i32,
|
||||||
|
storage_all_items: i32,
|
||||||
|
ability_id: i32,
|
||||||
|
special_ability: i32,
|
||||||
|
learn_ability_id: i32,
|
||||||
|
book_id: i32,
|
||||||
|
swap_item: i32,
|
||||||
|
icon_large: Option<Vec<u8>>,
|
||||||
|
icon_medium: Option<Vec<u8>>,
|
||||||
|
icon_small: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
let records = items.load::<ItemRecord>(conn)?;
|
let records = items.load::<ItemRecord>(conn)?;
|
||||||
|
|
||||||
let mut loaded_items = Vec::new();
|
let mut loaded_items = Vec::new();
|
||||||
for record in records {
|
for record in records {
|
||||||
|
// Load from JSON data column (contains complete item info including crafting recipes)
|
||||||
if let Ok(item) = serde_json::from_str::<Item>(&record.data) {
|
if let Ok(item) = serde_json::from_str::<Item>(&record.data) {
|
||||||
loaded_items.push(item);
|
loaded_items.push(item);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use diesel::prelude::*;
|
|||||||
use diesel::sqlite::SqliteConnection;
|
use diesel::sqlite::SqliteConnection;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::collections::HashMap;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -14,6 +15,9 @@ pub enum MinimapDatabaseError {
|
|||||||
#[error("Image processing error: {0}")]
|
#[error("Image processing error: {0}")]
|
||||||
ImageError(#[from] ImageProcessingError),
|
ImageError(#[from] ImageProcessingError),
|
||||||
|
|
||||||
|
#[error("Image load error: {0}")]
|
||||||
|
ImageLoadError(#[from] image::ImageError),
|
||||||
|
|
||||||
#[error("IO error: {0}")]
|
#[error("IO error: {0}")]
|
||||||
IoError(#[from] std::io::Error),
|
IoError(#[from] std::io::Error),
|
||||||
|
|
||||||
@@ -24,27 +28,15 @@ pub enum MinimapDatabaseError {
|
|||||||
ConnectionError(String),
|
ConnectionError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Database for managing minimap tiles with actual SQLite storage
|
/// Database for managing minimap tiles with merged zoom levels
|
||||||
pub struct MinimapDatabase {
|
pub struct MinimapDatabase {
|
||||||
database_url: String,
|
database_url: String,
|
||||||
image_processor: ImageProcessor,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MinimapDatabase {
|
impl MinimapDatabase {
|
||||||
/// Create new database connection
|
/// Create new database connection
|
||||||
pub fn new(database_url: String) -> Self {
|
pub fn new(database_url: String) -> Self {
|
||||||
Self {
|
Self { database_url }
|
||||||
database_url,
|
|
||||||
image_processor: ImageProcessor::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create with custom WebP quality
|
|
||||||
pub fn with_quality(database_url: String, quality: f32) -> Self {
|
|
||||||
Self {
|
|
||||||
database_url,
|
|
||||||
image_processor: ImageProcessor::new(quality),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Establish database connection
|
/// Establish database connection
|
||||||
@@ -53,59 +45,198 @@ impl MinimapDatabase {
|
|||||||
.map_err(|e| MinimapDatabaseError::ConnectionError(e.to_string()))
|
.map_err(|e| MinimapDatabaseError::ConnectionError(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load all PNG files from directory and process them into database
|
/// Load all PNG files from directory and process them into all zoom levels
|
||||||
pub fn load_from_directory<P: AsRef<Path>>(
|
pub fn load_from_directory<P: AsRef<Path>, B: AsRef<Path>>(
|
||||||
&self,
|
&self,
|
||||||
minimap_dir: P,
|
minimap_dir: P,
|
||||||
|
base_path: B,
|
||||||
) -> Result<usize, MinimapDatabaseError> {
|
) -> Result<usize, MinimapDatabaseError> {
|
||||||
use crate::schema::minimap_tiles;
|
use crate::schema::minimap_tiles;
|
||||||
|
|
||||||
let mut conn = self.establish_connection()?;
|
let mut conn = self.establish_connection()?;
|
||||||
|
|
||||||
|
println!("Loading PNG files from directory...");
|
||||||
|
let png_files = self.find_minimap_pngs(minimap_dir.as_ref())?;
|
||||||
|
println!("Found {} PNG files", png_files.len());
|
||||||
|
|
||||||
|
// Step 1: Process all original tiles (zoom level 2) and store their WebP data
|
||||||
|
println!("\nProcessing zoom level 2 (original tiles)...");
|
||||||
|
let mut tile_data: HashMap<(i32, i32), Vec<u8>> = HashMap::new();
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
|
|
||||||
// Find all PNG files
|
for png_path in &png_files {
|
||||||
let png_files = self.find_minimap_pngs(&minimap_dir)?;
|
let (x, y) = self.parse_coordinates(png_path)?;
|
||||||
|
|
||||||
for png_path in png_files {
|
// Load and encode as lossless WebP
|
||||||
// Parse coordinates from filename
|
let img = image::open(png_path)?;
|
||||||
let (x, y) = self.parse_coordinates(&png_path)?;
|
let rgba = img.to_rgba8();
|
||||||
|
let webp_data = ImageProcessor::encode_webp_lossless(&rgba)?;
|
||||||
// Process image
|
|
||||||
let processed = self.image_processor.process_minimap_png(&png_path)?;
|
|
||||||
|
|
||||||
// Get original file size
|
// Get original file size
|
||||||
let original_size = fs::metadata(&png_path)?.len() as i32;
|
let original_size = fs::metadata(png_path)?.len() as i32;
|
||||||
|
|
||||||
// Extract WebP blobs for each size
|
// Store in database
|
||||||
let webp_512 = processed.get(512).expect("512px resolution missing");
|
let relative_path = png_path.strip_prefix(base_path.as_ref()).unwrap_or(png_path);
|
||||||
let webp_256 = processed.get(256).expect("256px resolution missing");
|
|
||||||
let webp_128 = processed.get(128).expect("128px resolution missing");
|
|
||||||
let webp_64 = processed.get(64).expect("64px resolution missing");
|
|
||||||
|
|
||||||
// Create insertable record
|
|
||||||
let new_tile = NewMinimapTile {
|
let new_tile = NewMinimapTile {
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
original_width: 512,
|
zoom: 2,
|
||||||
original_height: 512,
|
width: 512,
|
||||||
|
height: 512,
|
||||||
original_file_size: Some(original_size),
|
original_file_size: Some(original_size),
|
||||||
webp_512,
|
image: &webp_data,
|
||||||
webp_256,
|
image_size: webp_data.len() as i32,
|
||||||
webp_128,
|
source_path: relative_path.to_str().unwrap_or(""),
|
||||||
webp_64,
|
|
||||||
webp_512_size: webp_512.len() as i32,
|
|
||||||
webp_256_size: webp_256.len() as i32,
|
|
||||||
webp_128_size: webp_128.len() as i32,
|
|
||||||
webp_64_size: webp_64.len() as i32,
|
|
||||||
source_path: png_path.to_str().unwrap_or(""),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Insert into database
|
diesel::replace_into(minimap_tiles::table)
|
||||||
diesel::insert_into(minimap_tiles::table)
|
|
||||||
.values(&new_tile)
|
.values(&new_tile)
|
||||||
.execute(&mut conn)?;
|
.execute(&mut conn)?;
|
||||||
|
|
||||||
|
// Cache for later merging
|
||||||
|
tile_data.insert((x, y), webp_data);
|
||||||
count += 1;
|
count += 1;
|
||||||
|
|
||||||
|
if count % 50 == 0 {
|
||||||
|
println!(" Processed {} tiles...", count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Processed {} zoom level 2 tiles", count);
|
||||||
|
|
||||||
|
// Get bounds for merging
|
||||||
|
let ((min_x, min_y), (max_x, max_y)) = self.get_map_bounds()?;
|
||||||
|
println!("\nMap bounds: X [{}, {}], Y [{}, {}]", min_x, max_x, min_y, max_y);
|
||||||
|
|
||||||
|
// Step 2: Generate zoom level 1 (2x2 merged)
|
||||||
|
println!("\nGenerating zoom level 1 (2x2 merged)...");
|
||||||
|
let zoom1_count = self.generate_merged_tiles(
|
||||||
|
&mut conn,
|
||||||
|
&tile_data,
|
||||||
|
min_x,
|
||||||
|
max_x,
|
||||||
|
min_y,
|
||||||
|
max_y,
|
||||||
|
1, // zoom level
|
||||||
|
2, // merge factor
|
||||||
|
)?;
|
||||||
|
println!("Generated {} zoom level 1 tiles", zoom1_count);
|
||||||
|
|
||||||
|
// Step 3: Generate zoom level 0 (4x4 merged)
|
||||||
|
println!("\nGenerating zoom level 0 (4x4 merged)...");
|
||||||
|
let zoom0_count = self.generate_merged_tiles(
|
||||||
|
&mut conn,
|
||||||
|
&tile_data,
|
||||||
|
min_x,
|
||||||
|
max_x,
|
||||||
|
min_y,
|
||||||
|
max_y,
|
||||||
|
0, // zoom level
|
||||||
|
4, // merge factor
|
||||||
|
)?;
|
||||||
|
println!("Generated {} zoom level 0 tiles", zoom0_count);
|
||||||
|
|
||||||
|
println!("\nTotal tiles generated:");
|
||||||
|
println!(" Zoom 2: {}", count);
|
||||||
|
println!(" Zoom 1: {}", zoom1_count);
|
||||||
|
println!(" Zoom 0: {}", zoom0_count);
|
||||||
|
println!(" Total: {}", count + zoom1_count + zoom0_count);
|
||||||
|
|
||||||
|
Ok(count + zoom1_count + zoom0_count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate merged tiles for a specific zoom level
|
||||||
|
fn generate_merged_tiles(
|
||||||
|
&self,
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
tile_data: &HashMap<(i32, i32), Vec<u8>>,
|
||||||
|
min_x: i32,
|
||||||
|
max_x: i32,
|
||||||
|
min_y: i32,
|
||||||
|
max_y: i32,
|
||||||
|
zoom_level: i32,
|
||||||
|
merge_factor: i32,
|
||||||
|
) -> Result<usize, MinimapDatabaseError> {
|
||||||
|
use crate::schema::minimap_tiles;
|
||||||
|
|
||||||
|
let mut count = 0;
|
||||||
|
|
||||||
|
// Iterate through merged tile grid
|
||||||
|
let mut merged_y = min_y;
|
||||||
|
while merged_y <= max_y {
|
||||||
|
let mut merged_x = min_x;
|
||||||
|
while merged_x <= max_x {
|
||||||
|
// Collect tiles for this merged tile
|
||||||
|
let mut tiles_for_merge: HashMap<(i32, i32), Vec<u8>> = HashMap::new();
|
||||||
|
let mut has_any_tile = false;
|
||||||
|
|
||||||
|
for dy in 0..merge_factor {
|
||||||
|
for dx in 0..merge_factor {
|
||||||
|
let tile_x = merged_x + dx;
|
||||||
|
let tile_y = merged_y + dy;
|
||||||
|
|
||||||
|
if let Some(webp) = tile_data.get(&(tile_x, tile_y)) {
|
||||||
|
tiles_for_merge.insert((dx, dy), webp.clone());
|
||||||
|
has_any_tile = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only create merged tile if we have at least one source tile
|
||||||
|
if has_any_tile {
|
||||||
|
let merged_img = ImageProcessor::merge_tiles(
|
||||||
|
&tiles_for_merge,
|
||||||
|
merge_factor,
|
||||||
|
merge_factor,
|
||||||
|
512,
|
||||||
|
512,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let merged_webp = ImageProcessor::encode_webp_lossless(&merged_img)?;
|
||||||
|
|
||||||
|
// Calculate merged tile coordinates
|
||||||
|
let merged_tile_x = merged_x / merge_factor;
|
||||||
|
let merged_tile_y = merged_y / merge_factor;
|
||||||
|
|
||||||
|
// Build source_tiles string for debugging
|
||||||
|
let mut source_coords = Vec::new();
|
||||||
|
for dy in 0..merge_factor {
|
||||||
|
for dx in 0..merge_factor {
|
||||||
|
let tx = merged_x + dx;
|
||||||
|
let ty = merged_y + dy;
|
||||||
|
if tile_data.contains_key(&(tx, ty)) {
|
||||||
|
source_coords.push(format!("{},{}", tx, ty));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let source_tiles = source_coords.join(";");
|
||||||
|
|
||||||
|
let new_tile = NewMinimapTile {
|
||||||
|
x: merged_tile_x,
|
||||||
|
y: merged_tile_y,
|
||||||
|
zoom: zoom_level,
|
||||||
|
width: 512,
|
||||||
|
height: 512,
|
||||||
|
original_file_size: None,
|
||||||
|
image: &merged_webp,
|
||||||
|
image_size: merged_webp.len() as i32,
|
||||||
|
source_path: &source_tiles,
|
||||||
|
};
|
||||||
|
|
||||||
|
diesel::replace_into(minimap_tiles::table)
|
||||||
|
.values(&new_tile)
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
merged_x += merge_factor;
|
||||||
|
}
|
||||||
|
merged_y += merge_factor;
|
||||||
|
|
||||||
|
if count % 20 == 0 && count > 0 {
|
||||||
|
println!(" Generated {} merged tiles...", count);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(count)
|
Ok(count)
|
||||||
@@ -165,55 +296,7 @@ impl MinimapDatabase {
|
|||||||
Ok((x, y))
|
Ok((x, y))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get tile by coordinates
|
/// Get map bounds (min/max x and y) from zoom level 2 tiles
|
||||||
pub fn get_tile(
|
|
||||||
&self,
|
|
||||||
x: i32,
|
|
||||||
y: i32,
|
|
||||||
) -> Result<Option<MinimapTileRecord>, MinimapDatabaseError> {
|
|
||||||
use crate::schema::minimap_tiles::dsl;
|
|
||||||
|
|
||||||
let mut conn = self.establish_connection()?;
|
|
||||||
|
|
||||||
let tile = dsl::minimap_tiles
|
|
||||||
.filter(dsl::x.eq(x))
|
|
||||||
.filter(dsl::y.eq(y))
|
|
||||||
.first::<MinimapTileRecord>(&mut conn)
|
|
||||||
.optional()?;
|
|
||||||
|
|
||||||
Ok(tile)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get tile WebP blob at specific size
|
|
||||||
pub fn get_tile_webp(
|
|
||||||
&self,
|
|
||||||
x: i32,
|
|
||||||
y: i32,
|
|
||||||
size: u32,
|
|
||||||
) -> Result<Option<Vec<u8>>, MinimapDatabaseError> {
|
|
||||||
let tile = self.get_tile(x, y)?;
|
|
||||||
|
|
||||||
Ok(tile.map(|t| match size {
|
|
||||||
512 => t.webp_512,
|
|
||||||
256 => t.webp_256,
|
|
||||||
128 => t.webp_128,
|
|
||||||
64 => t.webp_64,
|
|
||||||
_ => t.webp_512, // Default to 512
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all tiles
|
|
||||||
pub fn get_all_tiles(&self) -> Result<Vec<MinimapTileRecord>, MinimapDatabaseError> {
|
|
||||||
use crate::schema::minimap_tiles::dsl::*;
|
|
||||||
|
|
||||||
let mut conn = self.establish_connection()?;
|
|
||||||
|
|
||||||
let tiles = minimap_tiles.load::<MinimapTileRecord>(&mut conn)?;
|
|
||||||
|
|
||||||
Ok(tiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get map bounds (min/max x and y)
|
|
||||||
pub fn get_map_bounds(
|
pub fn get_map_bounds(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<((i32, i32), (i32, i32)), MinimapDatabaseError> {
|
) -> Result<((i32, i32), (i32, i32)), MinimapDatabaseError> {
|
||||||
@@ -222,30 +305,39 @@ impl MinimapDatabase {
|
|||||||
|
|
||||||
let mut conn = self.establish_connection()?;
|
let mut conn = self.establish_connection()?;
|
||||||
|
|
||||||
let (min_x, max_x): (Option<i32>, Option<i32>) =
|
let (min_x_val, max_x_val): (Option<i32>, Option<i32>) =
|
||||||
minimap_tiles.select((min(x), max(x))).first(&mut conn)?;
|
minimap_tiles
|
||||||
|
.filter(zoom.eq(2))
|
||||||
|
.select((min(x), max(x)))
|
||||||
|
.first(&mut conn)?;
|
||||||
|
|
||||||
let (min_y, max_y): (Option<i32>, Option<i32>) =
|
let (min_y_val, max_y_val): (Option<i32>, Option<i32>) =
|
||||||
minimap_tiles.select((min(y), max(y))).first(&mut conn)?;
|
minimap_tiles
|
||||||
|
.filter(zoom.eq(2))
|
||||||
|
.select((min(y), max(y)))
|
||||||
|
.first(&mut conn)?;
|
||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
(min_x.unwrap_or(0), min_y.unwrap_or(0)),
|
(min_x_val.unwrap_or(0), min_y_val.unwrap_or(0)),
|
||||||
(max_x.unwrap_or(0), max_y.unwrap_or(0)),
|
(max_x_val.unwrap_or(0), max_y_val.unwrap_or(0)),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get count of processed tiles
|
/// Get count of tiles at a specific zoom level
|
||||||
pub fn count(&self) -> Result<i64, MinimapDatabaseError> {
|
pub fn count_at_zoom(&self, zoom_level: i32) -> Result<i64, MinimapDatabaseError> {
|
||||||
use crate::schema::minimap_tiles::dsl::*;
|
use crate::schema::minimap_tiles::dsl::*;
|
||||||
use diesel::dsl::count_star;
|
use diesel::dsl::count_star;
|
||||||
|
|
||||||
let mut conn = self.establish_connection()?;
|
let mut conn = self.establish_connection()?;
|
||||||
let total = minimap_tiles.select(count_star()).first(&mut conn)?;
|
let total = minimap_tiles
|
||||||
|
.filter(zoom.eq(zoom_level))
|
||||||
|
.select(count_star())
|
||||||
|
.first(&mut conn)?;
|
||||||
|
|
||||||
Ok(total)
|
Ok(total)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get total storage size statistics
|
/// Get storage statistics
|
||||||
pub fn get_storage_stats(&self) -> Result<StorageStats, MinimapDatabaseError> {
|
pub fn get_storage_stats(&self) -> Result<StorageStats, MinimapDatabaseError> {
|
||||||
let mut conn = self.establish_connection()?;
|
let mut conn = self.establish_connection()?;
|
||||||
|
|
||||||
@@ -254,12 +346,17 @@ impl MinimapDatabase {
|
|||||||
|
|
||||||
let mut stats = StorageStats::default();
|
let mut stats = StorageStats::default();
|
||||||
for tile in tiles {
|
for tile in tiles {
|
||||||
stats.total_original_size += tile.original_file_size.unwrap_or(0) as i64;
|
if tile.zoom == 2 {
|
||||||
stats.total_webp_512 += tile.webp_512_size as i64;
|
stats.total_original_size += tile.original_file_size.unwrap_or(0) as i64;
|
||||||
stats.total_webp_256 += tile.webp_256_size as i64;
|
stats.zoom2_count += 1;
|
||||||
stats.total_webp_128 += tile.webp_128_size as i64;
|
stats.zoom2_size += tile.image_size as i64;
|
||||||
stats.total_webp_64 += tile.webp_64_size as i64;
|
} else if tile.zoom == 1 {
|
||||||
stats.tile_count += 1;
|
stats.zoom1_count += 1;
|
||||||
|
stats.zoom1_size += tile.image_size as i64;
|
||||||
|
} else if tile.zoom == 0 {
|
||||||
|
stats.zoom0_count += 1;
|
||||||
|
stats.zoom0_size += tile.image_size as i64;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(stats)
|
Ok(stats)
|
||||||
@@ -268,17 +365,18 @@ impl MinimapDatabase {
|
|||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct StorageStats {
|
pub struct StorageStats {
|
||||||
pub tile_count: i64,
|
pub zoom2_count: i64,
|
||||||
|
pub zoom1_count: i64,
|
||||||
|
pub zoom0_count: i64,
|
||||||
pub total_original_size: i64,
|
pub total_original_size: i64,
|
||||||
pub total_webp_512: i64,
|
pub zoom2_size: i64,
|
||||||
pub total_webp_256: i64,
|
pub zoom1_size: i64,
|
||||||
pub total_webp_128: i64,
|
pub zoom0_size: i64,
|
||||||
pub total_webp_64: i64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StorageStats {
|
impl StorageStats {
|
||||||
pub fn total_webp_size(&self) -> i64 {
|
pub fn total_webp_size(&self) -> i64 {
|
||||||
self.total_webp_512 + self.total_webp_256 + self.total_webp_128 + self.total_webp_64
|
self.zoom2_size + self.zoom1_size + self.zoom0_size
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn compression_ratio(&self) -> f64 {
|
pub fn compression_ratio(&self) -> f64 {
|
||||||
|
|||||||
@@ -259,6 +259,83 @@ impl ImageProcessor {
|
|||||||
let webp_data = encoder.encode(self.quality);
|
let webp_data = encoder.encode(self.quality);
|
||||||
Ok(webp_data.to_vec())
|
Ok(webp_data.to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Encode image to lossless WebP
|
||||||
|
pub fn encode_webp_lossless(
|
||||||
|
img: &RgbaImage,
|
||||||
|
) -> Result<Vec<u8>, ImageProcessingError> {
|
||||||
|
let (w, h) = img.dimensions();
|
||||||
|
let encoder = webp::Encoder::from_rgba(img.as_raw(), w, h);
|
||||||
|
let webp_data = encoder.encode_lossless();
|
||||||
|
Ok(webp_data.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a black tile of specified size
|
||||||
|
pub fn create_black_tile(size: u32) -> RgbaImage {
|
||||||
|
image::ImageBuffer::from_pixel(size, size, Rgba([0, 0, 0, 255]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge multiple tiles into a single image
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `tiles` - HashMap of (x, y) coordinates to tile image data (WebP format)
|
||||||
|
/// * `grid_x` - Number of tiles in X direction
|
||||||
|
/// * `grid_y` - Number of tiles in Y direction
|
||||||
|
/// * `tile_size` - Size of each original tile (assumes square tiles)
|
||||||
|
/// * `output_size` - Size of the output merged image
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// A merged RgbaImage containing all tiles positioned correctly
|
||||||
|
pub fn merge_tiles(
|
||||||
|
tiles: &HashMap<(i32, i32), Vec<u8>>,
|
||||||
|
grid_x: i32,
|
||||||
|
grid_y: i32,
|
||||||
|
tile_size: u32,
|
||||||
|
output_size: u32,
|
||||||
|
) -> Result<RgbaImage, ImageProcessingError> {
|
||||||
|
// Create output image
|
||||||
|
let mut merged = Self::create_black_tile(output_size);
|
||||||
|
|
||||||
|
// Calculate size each tile should be in the output
|
||||||
|
let scaled_tile_size = output_size / grid_x.max(grid_y) as u32;
|
||||||
|
|
||||||
|
// Process each tile in the grid
|
||||||
|
for dy in 0..grid_y {
|
||||||
|
for dx in 0..grid_x {
|
||||||
|
if let Some(webp_data) = tiles.get(&(dx, dy)) {
|
||||||
|
// Decode WebP tile
|
||||||
|
if let Ok(tile_img) = image::load_from_memory_with_format(
|
||||||
|
webp_data,
|
||||||
|
image::ImageFormat::WebP,
|
||||||
|
) {
|
||||||
|
// Resize tile to fit in output
|
||||||
|
let resized = tile_img.resize_exact(
|
||||||
|
scaled_tile_size,
|
||||||
|
scaled_tile_size,
|
||||||
|
image::imageops::FilterType::Lanczos3,
|
||||||
|
).to_rgba8();
|
||||||
|
|
||||||
|
// Calculate position in output image
|
||||||
|
let offset_x = dx as u32 * scaled_tile_size;
|
||||||
|
// Invert Y-axis to match expected coordinate system
|
||||||
|
let offset_y = (grid_y - 1 - dy) as u32 * scaled_tile_size;
|
||||||
|
|
||||||
|
// Copy pixels into merged image
|
||||||
|
for y in 0..scaled_tile_size {
|
||||||
|
for x in 0..scaled_tile_size {
|
||||||
|
if let Some(pixel) = resized.get_pixel_checked(x, y) {
|
||||||
|
merged.put_pixel(offset_x + x, offset_y + y, *pixel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If tile doesn't exist, it stays black (already initialized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(merged)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ImageProcessor {
|
impl Default for ImageProcessor {
|
||||||
|
|||||||
@@ -91,6 +91,12 @@ pub use types::{
|
|||||||
MAX_STACK,
|
MAX_STACK,
|
||||||
// Other types
|
// Other types
|
||||||
InteractableResource,
|
InteractableResource,
|
||||||
|
InteractableTeleporter,
|
||||||
|
InteractableWorkbench,
|
||||||
|
LootSpawner,
|
||||||
|
MapIcon,
|
||||||
|
MapIconType,
|
||||||
|
MapNameChanger,
|
||||||
Npc,
|
Npc,
|
||||||
NpcStat,
|
NpcStat,
|
||||||
NpcLevel,
|
NpcLevel,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use unity_parser::log::DedupLogger;
|
|||||||
use log::{info, error, warn, LevelFilter};
|
use log::{info, error, warn, LevelFilter};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use diesel::sqlite::SqliteConnection;
|
use diesel::sqlite::SqliteConnection;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|
||||||
@@ -27,49 +28,51 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// Load items from XML
|
// Load items from XML
|
||||||
info!("📚 Loading game data from XML...");
|
info!("📚 Loading game data from XML...");
|
||||||
|
|
||||||
let items_path = "/home/connor/repos/CBAssets/Data/XMLs/Items/Items.xml";
|
let cb_assets_path = env::var("CB_ASSETS_PATH").unwrap_or_else(|_| "/home/connor/repos/CBAssets".to_string());
|
||||||
|
let items_path = format!("{}/Data/XMLs/Items/Items.xml", cb_assets_path);
|
||||||
let item_db = ItemDatabase::load_from_xml(items_path)?;
|
let item_db = ItemDatabase::load_from_xml(items_path)?;
|
||||||
info!("✅ Loaded {} items", item_db.len());
|
info!("✅ Loaded {} items", item_db.len());
|
||||||
|
|
||||||
let npcs_path = "/home/connor/repos/CBAssets/Data/XMLs/Npcs/NPCInfo.xml";
|
let npcs_path = format!("{}/Data/XMLs/Npcs/NPCInfo.xml", cb_assets_path);
|
||||||
let npc_db = NpcDatabase::load_from_xml(npcs_path)?;
|
let npc_db = NpcDatabase::load_from_xml(npcs_path)?;
|
||||||
info!("✅ Loaded {} NPCs", npc_db.len());
|
info!("✅ Loaded {} NPCs", npc_db.len());
|
||||||
|
|
||||||
let quests_path = "/home/connor/repos/CBAssets/Data/XMLs/Quests/Quests.xml";
|
let quests_path = format!("{}/Data/XMLs/Quests/Quests.xml", cb_assets_path);
|
||||||
let quest_db = QuestDatabase::load_from_xml(quests_path)?;
|
let quest_db = QuestDatabase::load_from_xml(quests_path)?;
|
||||||
info!("✅ Loaded {} quests", quest_db.len());
|
info!("✅ Loaded {} quests", quest_db.len());
|
||||||
|
|
||||||
let harvestables_path = "/home/connor/repos/CBAssets/Data/XMLs/Harvestables/HarvestableInfo.xml";
|
let harvestables_path = format!("{}/Data/XMLs/Harvestables/HarvestableInfo.xml", cb_assets_path);
|
||||||
let harvestable_db = HarvestableDatabase::load_from_xml(harvestables_path)?;
|
let harvestable_db = HarvestableDatabase::load_from_xml(harvestables_path)?;
|
||||||
info!("✅ Loaded {} harvestables", harvestable_db.len());
|
info!("✅ Loaded {} harvestables", harvestable_db.len());
|
||||||
|
|
||||||
let loot_path = "/home/connor/repos/CBAssets/Data/XMLs/Loot/Loot.xml";
|
let loot_path = format!("{}/Data/XMLs/Loot/Loot.xml", cb_assets_path);
|
||||||
let loot_db = LootDatabase::load_from_xml(loot_path)?;
|
let loot_db = LootDatabase::load_from_xml(loot_path)?;
|
||||||
info!("✅ Loaded {} loot tables", loot_db.len());
|
info!("✅ Loaded {} loot tables", loot_db.len());
|
||||||
|
|
||||||
let maps_path = "/home/connor/repos/CBAssets/Data/XMLs/Maps/Maps.xml";
|
let maps_path = format!("{}/Data/XMLs/Maps/Maps.xml", cb_assets_path);
|
||||||
let map_db = MapDatabase::load_from_xml(maps_path)?;
|
let map_db = MapDatabase::load_from_xml(maps_path)?;
|
||||||
info!("✅ Loaded {} maps", map_db.len());
|
info!("✅ Loaded {} maps", map_db.len());
|
||||||
|
|
||||||
let fast_travel_dir = "/home/connor/repos/CBAssets/Data/XMLs";
|
let fast_travel_dir = format!("{}/Data/XMLs", cb_assets_path);
|
||||||
let fast_travel_db = FastTravelDatabase::load_from_directory(fast_travel_dir)?;
|
let fast_travel_db = FastTravelDatabase::load_from_directory(fast_travel_dir)?;
|
||||||
info!("✅ Loaded {} fast travel locations", fast_travel_db.len());
|
info!("✅ Loaded {} fast travel locations", fast_travel_db.len());
|
||||||
|
|
||||||
let player_houses_path = "/home/connor/repos/CBAssets/Data/XMLs/PlayerHouses/PlayerHouses.xml";
|
let player_houses_path = format!("{}/Data/XMLs/PlayerHouses/PlayerHouses.xml", cb_assets_path);
|
||||||
let player_house_db = PlayerHouseDatabase::load_from_xml(player_houses_path)?;
|
let player_house_db = PlayerHouseDatabase::load_from_xml(player_houses_path)?;
|
||||||
info!("✅ Loaded {} player houses", player_house_db.len());
|
info!("✅ Loaded {} player houses", player_house_db.len());
|
||||||
|
|
||||||
let traits_path = "/home/connor/repos/CBAssets/Data/XMLs/Traits/Traits.xml";
|
let traits_path = format!("{}/Data/XMLs/Traits/Traits.xml", cb_assets_path);
|
||||||
let trait_db = TraitDatabase::load_from_xml(traits_path)?;
|
let trait_db = TraitDatabase::load_from_xml(traits_path)?;
|
||||||
info!("✅ Loaded {} traits", trait_db.len());
|
info!("✅ Loaded {} traits", trait_db.len());
|
||||||
|
|
||||||
let shops_path = "/home/connor/repos/CBAssets/Data/XMLs/Shops/Shops.xml";
|
let shops_path = format!("{}/Data/XMLs/Shops/Shops.xml", cb_assets_path);
|
||||||
let shop_db = ShopDatabase::load_from_xml(shops_path)?;
|
let shop_db = ShopDatabase::load_from_xml(shops_path)?;
|
||||||
info!("✅ Loaded {} shops", shop_db.len());
|
info!("✅ Loaded {} shops", shop_db.len());
|
||||||
|
|
||||||
// Save to SQLite database
|
// Save to SQLite database
|
||||||
info!("\n💾 Saving game data to SQLite database...");
|
info!("\n💾 Saving game data to SQLite database...");
|
||||||
let mut conn = SqliteConnection::establish("cursebreaker.db")?;
|
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "../cursebreaker.db".to_string());
|
||||||
|
let mut conn = SqliteConnection::establish(&database_url)?;
|
||||||
|
|
||||||
match item_db.save_to_db(&mut conn) {
|
match item_db.save_to_db(&mut conn) {
|
||||||
Ok(count) => info!("✅ Saved {} items to database", count),
|
Ok(count) => info!("✅ Saved {} items to database", count),
|
||||||
@@ -145,7 +148,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
info!(" • Tables with conditional drops: {}", loot_db.get_conditional_tables().len());
|
info!(" • Tables with conditional drops: {}", loot_db.get_conditional_tables().len());
|
||||||
|
|
||||||
// Initialize Unity project once - scans entire project for GUID mappings
|
// Initialize Unity project once - scans entire project for GUID mappings
|
||||||
let project_root = Path::new("/home/connor/repos/CBAssets");
|
let project_root = Path::new(&cb_assets_path);
|
||||||
info!("\n📦 Initializing Unity project from: {}", project_root.display());
|
info!("\n📦 Initializing Unity project from: {}", project_root.display());
|
||||||
|
|
||||||
let project = UnityProject::from_path(project_root)?;
|
let project = UnityProject::from_path(project_root)?;
|
||||||
@@ -195,10 +198,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
// Process minimap tiles
|
// Process minimap tiles
|
||||||
info!("\n🗺️ Processing minimap tiles...");
|
info!("\n🗺️ Processing minimap tiles...");
|
||||||
let minimap_db = MinimapDatabase::new("cursebreaker.db".to_string());
|
let minimap_db = MinimapDatabase::new(database_url.clone());
|
||||||
|
|
||||||
let minimap_path = "/home/connor/repos/CBAssets/Data/Textures/MinimapSquares";
|
let minimap_path = format!("{}/Data/Textures/MinimapSquares", cb_assets_path);
|
||||||
match minimap_db.load_from_directory(minimap_path) {
|
match minimap_db.load_from_directory(&minimap_path, &cb_assets_path) {
|
||||||
Ok(count) => {
|
Ok(count) => {
|
||||||
info!("✅ Processed {} minimap tiles", count);
|
info!("✅ Processed {} minimap tiles", count);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
// @generated automatically by Diesel CLI.
|
// @generated automatically by Diesel CLI.
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
crafting_recipe_items (recipe_id, item_id) {
|
||||||
|
recipe_id -> Integer,
|
||||||
|
item_id -> Integer,
|
||||||
|
amount -> Integer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
crafting_recipes (id) {
|
||||||
|
id -> Nullable<Integer>,
|
||||||
|
product_item_id -> Integer,
|
||||||
|
skill -> Text,
|
||||||
|
level -> Integer,
|
||||||
|
workbench_id -> Integer,
|
||||||
|
xp -> Integer,
|
||||||
|
unlocked_by_default -> Integer,
|
||||||
|
checks -> Nullable<Text>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
fast_travel_locations (id) {
|
fast_travel_locations (id) {
|
||||||
id -> Nullable<Integer>,
|
id -> Nullable<Integer>,
|
||||||
@@ -10,10 +31,41 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
harvestables (id) {
|
harvestable_drops (id) {
|
||||||
id -> Nullable<Integer>,
|
id -> Nullable<Integer>,
|
||||||
|
harvestable_id -> Integer,
|
||||||
|
item_id -> Integer,
|
||||||
|
minamount -> Integer,
|
||||||
|
maxamount -> Integer,
|
||||||
|
droprate -> Integer,
|
||||||
|
droprateboost -> Integer,
|
||||||
|
amountboost -> Integer,
|
||||||
|
comment -> Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
harvestables (id) {
|
||||||
|
id -> Integer,
|
||||||
name -> Text,
|
name -> Text,
|
||||||
data -> Text,
|
description -> Text,
|
||||||
|
comment -> Text,
|
||||||
|
level -> Integer,
|
||||||
|
skill -> Text,
|
||||||
|
tool -> Text,
|
||||||
|
min_health -> Integer,
|
||||||
|
max_health -> Integer,
|
||||||
|
harvesttime -> Integer,
|
||||||
|
hittime -> Integer,
|
||||||
|
respawntime -> Integer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
item_stats (item_id, stat_type) {
|
||||||
|
item_id -> Integer,
|
||||||
|
stat_type -> Text,
|
||||||
|
value -> Float,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +74,30 @@ diesel::table! {
|
|||||||
id -> Nullable<Integer>,
|
id -> Nullable<Integer>,
|
||||||
name -> Text,
|
name -> Text,
|
||||||
data -> Text,
|
data -> Text,
|
||||||
|
item_type -> Text,
|
||||||
|
level -> Integer,
|
||||||
|
price -> Integer,
|
||||||
|
max_stack -> Integer,
|
||||||
|
storage_size -> Integer,
|
||||||
|
skill -> Text,
|
||||||
|
tool -> Text,
|
||||||
|
description -> Text,
|
||||||
|
two_handed -> Integer,
|
||||||
|
undroppable -> Integer,
|
||||||
|
undroppable_on_death -> Integer,
|
||||||
|
unequip_destroy -> Integer,
|
||||||
|
generate_icon -> Integer,
|
||||||
|
hide_milestone -> Integer,
|
||||||
|
cannot_craft_exceptional -> Integer,
|
||||||
|
storage_all_items -> Integer,
|
||||||
|
ability_id -> Integer,
|
||||||
|
special_ability -> Integer,
|
||||||
|
learn_ability_id -> Integer,
|
||||||
|
book_id -> Integer,
|
||||||
|
swap_item -> Integer,
|
||||||
|
icon_large -> Nullable<Binary>,
|
||||||
|
icon_medium -> Nullable<Binary>,
|
||||||
|
icon_small -> Nullable<Binary>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,17 +122,12 @@ diesel::table! {
|
|||||||
id -> Nullable<Integer>,
|
id -> Nullable<Integer>,
|
||||||
x -> Integer,
|
x -> Integer,
|
||||||
y -> Integer,
|
y -> Integer,
|
||||||
original_width -> Integer,
|
zoom -> Integer,
|
||||||
original_height -> Integer,
|
width -> Integer,
|
||||||
|
height -> Integer,
|
||||||
original_file_size -> Nullable<Integer>,
|
original_file_size -> Nullable<Integer>,
|
||||||
webp_512 -> Binary,
|
image -> Binary,
|
||||||
webp_256 -> Binary,
|
image_size -> Integer,
|
||||||
webp_128 -> Binary,
|
|
||||||
webp_64 -> Binary,
|
|
||||||
webp_512_size -> Integer,
|
|
||||||
webp_256_size -> Integer,
|
|
||||||
webp_128_size -> Integer,
|
|
||||||
webp_64_size -> Integer,
|
|
||||||
processed_at -> Timestamp,
|
processed_at -> Timestamp,
|
||||||
source_path -> Text,
|
source_path -> Text,
|
||||||
}
|
}
|
||||||
@@ -87,6 +158,14 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
resource_icons (item_id) {
|
||||||
|
item_id -> Integer,
|
||||||
|
name -> Text,
|
||||||
|
icon_64 -> Binary,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
shops (id) {
|
shops (id) {
|
||||||
id -> Nullable<Integer>,
|
id -> Nullable<Integer>,
|
||||||
@@ -107,9 +186,77 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
world_loot (pos_x, pos_y) {
|
||||||
|
pos_x -> Float,
|
||||||
|
pos_y -> Float,
|
||||||
|
item_id -> Integer,
|
||||||
|
amount -> Integer,
|
||||||
|
respawn_time -> Integer,
|
||||||
|
visibility_checks -> Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
world_map_icons (pos_x, pos_y) {
|
||||||
|
pos_x -> Float,
|
||||||
|
pos_y -> Float,
|
||||||
|
icon_type -> Integer,
|
||||||
|
icon_size -> Integer,
|
||||||
|
icon -> Text,
|
||||||
|
text -> Text,
|
||||||
|
font_size -> Integer,
|
||||||
|
hover_text -> Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
world_map_name_changers (pos_x, pos_y) {
|
||||||
|
pos_x -> Float,
|
||||||
|
pos_y -> Float,
|
||||||
|
map_name -> Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
world_resources (item_id, pos_x, pos_y) {
|
||||||
|
item_id -> Integer,
|
||||||
|
pos_x -> Float,
|
||||||
|
pos_y -> Float,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
world_teleporters (pos_x, pos_y) {
|
||||||
|
pos_x -> Float,
|
||||||
|
pos_y -> Float,
|
||||||
|
tp_x -> Nullable<Float>,
|
||||||
|
tp_y -> Nullable<Float>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
world_workbenches (pos_x, pos_y) {
|
||||||
|
pos_x -> Float,
|
||||||
|
pos_y -> Float,
|
||||||
|
workbench_id -> Integer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::joinable!(crafting_recipe_items -> crafting_recipes (recipe_id));
|
||||||
|
diesel::joinable!(crafting_recipe_items -> items (item_id));
|
||||||
|
diesel::joinable!(crafting_recipes -> items (product_item_id));
|
||||||
|
diesel::joinable!(harvestable_drops -> harvestables (harvestable_id));
|
||||||
|
diesel::joinable!(harvestable_drops -> items (item_id));
|
||||||
|
diesel::joinable!(item_stats -> items (item_id));
|
||||||
|
|
||||||
diesel::allow_tables_to_appear_in_same_query!(
|
diesel::allow_tables_to_appear_in_same_query!(
|
||||||
|
crafting_recipe_items,
|
||||||
|
crafting_recipes,
|
||||||
fast_travel_locations,
|
fast_travel_locations,
|
||||||
|
harvestable_drops,
|
||||||
harvestables,
|
harvestables,
|
||||||
|
item_stats,
|
||||||
items,
|
items,
|
||||||
loot_tables,
|
loot_tables,
|
||||||
maps,
|
maps,
|
||||||
@@ -117,6 +264,13 @@ diesel::allow_tables_to_appear_in_same_query!(
|
|||||||
npcs,
|
npcs,
|
||||||
player_houses,
|
player_houses,
|
||||||
quests,
|
quests,
|
||||||
|
resource_icons,
|
||||||
shops,
|
shops,
|
||||||
traits,
|
traits,
|
||||||
|
world_loot,
|
||||||
|
world_map_icons,
|
||||||
|
world_map_name_changers,
|
||||||
|
world_resources,
|
||||||
|
world_teleporters,
|
||||||
|
world_workbenches,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use super::item::{SkillType, Tool};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Harvestable {
|
pub struct Harvestable {
|
||||||
@@ -7,40 +8,41 @@ pub struct Harvestable {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
||||||
// Basic attributes
|
// Basic attributes
|
||||||
pub actionname: Option<String>,
|
pub actionname: String,
|
||||||
pub desc: Option<String>,
|
pub desc: String,
|
||||||
pub comment: Option<String>,
|
pub comment: String,
|
||||||
pub level: Option<i32>,
|
pub level: i32,
|
||||||
pub skill: Option<String>,
|
pub skill: SkillType,
|
||||||
pub tool: Option<String>,
|
pub tool: Tool,
|
||||||
|
|
||||||
// Health (can be range like "3-5" or single value)
|
// Health
|
||||||
pub health: Option<String>,
|
pub min_health: i32,
|
||||||
|
pub max_health: i32,
|
||||||
|
|
||||||
// Timing
|
// Timing
|
||||||
pub harvesttime: Option<i32>,
|
pub harvesttime: i32,
|
||||||
pub hittime: Option<i32>,
|
pub hittime: i32,
|
||||||
pub respawntime: Option<i32>,
|
pub respawntime: i32,
|
||||||
|
|
||||||
// Audio
|
// Audio
|
||||||
pub harvestsfx: Option<String>,
|
pub harvestsfx: String,
|
||||||
pub endsfx: Option<String>,
|
pub endsfx: String,
|
||||||
pub receiveitemsfx: Option<String>,
|
pub receiveitemsfx: String,
|
||||||
|
|
||||||
// Visuals
|
// Visuals
|
||||||
pub animation: Option<String>,
|
pub animation: String,
|
||||||
pub takehitanimation: Option<String>,
|
pub takehitanimation: String,
|
||||||
pub endgfx: Option<String>,
|
pub endgfx: String,
|
||||||
|
|
||||||
// Behavior flags
|
// Behavior flags
|
||||||
pub tree: Option<i32>,
|
pub tree: bool,
|
||||||
pub hidemilestone: Option<i32>,
|
pub hidemilestone: bool,
|
||||||
pub nohighlight: Option<i32>,
|
pub nohighlight: bool,
|
||||||
pub hideminimap: Option<i32>,
|
pub hideminimap: bool,
|
||||||
pub noleftclickinteract: Option<i32>,
|
pub noleftclickinteract: bool,
|
||||||
|
|
||||||
// Interaction
|
// Interaction
|
||||||
pub interactdistance: Option<String>,
|
pub interactdistance: String,
|
||||||
|
|
||||||
// Drops
|
// Drops
|
||||||
pub drops: Vec<HarvestableDrop>,
|
pub drops: Vec<HarvestableDrop>,
|
||||||
@@ -49,14 +51,14 @@ pub struct Harvestable {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct HarvestableDrop {
|
pub struct HarvestableDrop {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub minamount: Option<i32>,
|
pub minamount: i32,
|
||||||
pub maxamount: Option<i32>,
|
pub maxamount: i32,
|
||||||
pub droprate: Option<i32>,
|
pub droprate: i32,
|
||||||
pub droprateboost: Option<i32>,
|
pub droprateboost: i32,
|
||||||
pub amountboost: Option<i32>,
|
pub amountboost: i32,
|
||||||
pub checks: Option<String>,
|
pub checks: String,
|
||||||
pub comment: Option<String>,
|
pub comment: String,
|
||||||
pub dontconsumehealth: Option<i32>,
|
pub dontconsumehealth: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Harvestable {
|
impl Harvestable {
|
||||||
@@ -64,45 +66,46 @@ impl Harvestable {
|
|||||||
Self {
|
Self {
|
||||||
typeid,
|
typeid,
|
||||||
name,
|
name,
|
||||||
actionname: None,
|
actionname: String::new(),
|
||||||
desc: None,
|
desc: String::new(),
|
||||||
comment: None,
|
comment: String::new(),
|
||||||
level: None,
|
level: 0,
|
||||||
skill: None,
|
skill: SkillType::None,
|
||||||
tool: None,
|
tool: Tool::None,
|
||||||
health: None,
|
min_health: 0,
|
||||||
harvesttime: None,
|
max_health: 0,
|
||||||
hittime: None,
|
harvesttime: 0,
|
||||||
respawntime: None,
|
hittime: 0,
|
||||||
harvestsfx: None,
|
respawntime: 0,
|
||||||
endsfx: None,
|
harvestsfx: String::new(),
|
||||||
receiveitemsfx: None,
|
endsfx: String::new(),
|
||||||
animation: None,
|
receiveitemsfx: String::new(),
|
||||||
takehitanimation: None,
|
animation: String::new(),
|
||||||
endgfx: None,
|
takehitanimation: String::new(),
|
||||||
tree: None,
|
endgfx: String::new(),
|
||||||
hidemilestone: None,
|
tree: false,
|
||||||
nohighlight: None,
|
hidemilestone: false,
|
||||||
hideminimap: None,
|
nohighlight: false,
|
||||||
noleftclickinteract: None,
|
hideminimap: false,
|
||||||
interactdistance: None,
|
noleftclickinteract: false,
|
||||||
|
interactdistance: String::new(),
|
||||||
drops: Vec::new(),
|
drops: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if this is a tree
|
/// Check if this is a tree
|
||||||
pub fn is_tree(&self) -> bool {
|
pub fn is_tree(&self) -> bool {
|
||||||
self.tree == Some(1)
|
self.tree
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if this requires a tool
|
/// Check if this requires a tool
|
||||||
pub fn requires_tool(&self) -> bool {
|
pub fn requires_tool(&self) -> bool {
|
||||||
self.tool.is_some()
|
!matches!(self.tool, Tool::None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the skill associated with this harvestable
|
/// Get the skill associated with this harvestable
|
||||||
pub fn get_skill(&self) -> Option<&str> {
|
pub fn get_skill(&self) -> SkillType {
|
||||||
self.skill.as_deref()
|
self.skill
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all item IDs that can drop from this harvestable
|
/// Get all item IDs that can drop from this harvestable
|
||||||
|
|||||||
@@ -9,17 +9,12 @@ pub struct MinimapTileRecord {
|
|||||||
pub id: Option<i32>,
|
pub id: Option<i32>,
|
||||||
pub x: i32,
|
pub x: i32,
|
||||||
pub y: i32,
|
pub y: i32,
|
||||||
pub original_width: i32,
|
pub zoom: i32,
|
||||||
pub original_height: i32,
|
pub width: i32,
|
||||||
|
pub height: i32,
|
||||||
pub original_file_size: Option<i32>,
|
pub original_file_size: Option<i32>,
|
||||||
pub webp_512: Vec<u8>,
|
pub image: Vec<u8>,
|
||||||
pub webp_256: Vec<u8>,
|
pub image_size: i32,
|
||||||
pub webp_128: Vec<u8>,
|
|
||||||
pub webp_64: Vec<u8>,
|
|
||||||
pub webp_512_size: i32,
|
|
||||||
pub webp_256_size: i32,
|
|
||||||
pub webp_128_size: i32,
|
|
||||||
pub webp_64_size: i32,
|
|
||||||
pub processed_at: String, // SQLite TIMESTAMP as String
|
pub processed_at: String, // SQLite TIMESTAMP as String
|
||||||
pub source_path: String,
|
pub source_path: String,
|
||||||
}
|
}
|
||||||
@@ -30,16 +25,11 @@ pub struct MinimapTileRecord {
|
|||||||
pub struct NewMinimapTile<'a> {
|
pub struct NewMinimapTile<'a> {
|
||||||
pub x: i32,
|
pub x: i32,
|
||||||
pub y: i32,
|
pub y: i32,
|
||||||
pub original_width: i32,
|
pub zoom: i32,
|
||||||
pub original_height: i32,
|
pub width: i32,
|
||||||
|
pub height: i32,
|
||||||
pub original_file_size: Option<i32>,
|
pub original_file_size: Option<i32>,
|
||||||
pub webp_512: &'a [u8],
|
pub image: &'a [u8],
|
||||||
pub webp_256: &'a [u8],
|
pub image_size: i32,
|
||||||
pub webp_128: &'a [u8],
|
|
||||||
pub webp_64: &'a [u8],
|
|
||||||
pub webp_512_size: i32,
|
|
||||||
pub webp_256_size: i32,
|
|
||||||
pub webp_128_size: i32,
|
|
||||||
pub webp_64_size: i32,
|
|
||||||
pub source_path: &'a str,
|
pub source_path: &'a str,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,14 +15,12 @@ use serde_yaml::Mapping;
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct InteractableResource {
|
pub struct InteractableResource {
|
||||||
pub max_health: i64,
|
|
||||||
pub type_id: i64,
|
pub type_id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UnityComponent for InteractableResource {
|
impl UnityComponent for InteractableResource {
|
||||||
fn parse(yaml: &Mapping, _ctx: &ComponentContext) -> Option<Self> {
|
fn parse(yaml: &Mapping, _ctx: &ComponentContext) -> Option<Self> {
|
||||||
Some(Self {
|
Some(Self {
|
||||||
max_health: unity_parser::yaml_helpers::get_i64(yaml, "maxHealth").unwrap_or(0),
|
|
||||||
type_id: unity_parser::yaml_helpers::get_i64(yaml, "typeId").unwrap_or(0),
|
type_id: unity_parser::yaml_helpers::get_i64(yaml, "typeId").unwrap_or(0),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use crate::types::{
|
|||||||
PlayerHouse,
|
PlayerHouse,
|
||||||
Trait, TraitTrainer,
|
Trait, TraitTrainer,
|
||||||
Shop, ShopItem,
|
Shop, ShopItem,
|
||||||
|
SkillType, Tool,
|
||||||
};
|
};
|
||||||
use quick_xml::events::Event;
|
use quick_xml::events::Event;
|
||||||
use quick_xml::reader::Reader;
|
use quick_xml::reader::Reader;
|
||||||
@@ -180,6 +181,20 @@ fn parse_stat(attrs: &HashMap<String, String>) -> ItemStat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse health range string like "3-5" or "3" into (min, max)
|
||||||
|
fn parse_health_range(health_str: &str) -> (i32, i32) {
|
||||||
|
if let Some(dash_pos) = health_str.find('-') {
|
||||||
|
let min_str = &health_str[..dash_pos];
|
||||||
|
let max_str = &health_str[dash_pos + 1..];
|
||||||
|
let min = min_str.trim().parse().unwrap_or(0);
|
||||||
|
let max = max_str.trim().parse().unwrap_or(0);
|
||||||
|
(min, max)
|
||||||
|
} else {
|
||||||
|
let val = health_str.trim().parse().unwrap_or(0);
|
||||||
|
(val, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// NPC Parser
|
// NPC Parser
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -549,45 +564,49 @@ pub fn parse_harvestables_xml<P: AsRef<Path>>(path: P) -> Result<Vec<Harvestable
|
|||||||
|
|
||||||
let mut harvestable = Harvestable::new(typeid, name);
|
let mut harvestable = Harvestable::new(typeid, name);
|
||||||
|
|
||||||
// Parse optional attributes
|
// Parse optional attributes with defaults
|
||||||
if let Some(v) = attrs.get("actionname") { harvestable.actionname = Some(v.clone()); }
|
if let Some(v) = attrs.get("actionname") { harvestable.actionname = v.clone(); }
|
||||||
if let Some(v) = attrs.get("desc") { harvestable.desc = Some(v.clone()); }
|
if let Some(v) = attrs.get("desc") { harvestable.desc = v.clone(); }
|
||||||
if let Some(v) = attrs.get("comment") { harvestable.comment = Some(v.clone()); }
|
if let Some(v) = attrs.get("comment") { harvestable.comment = v.clone(); }
|
||||||
if let Some(v) = attrs.get("level") { harvestable.level = v.parse().ok(); }
|
if let Some(v) = attrs.get("level") { harvestable.level = v.parse().unwrap_or(0); }
|
||||||
if let Some(v) = attrs.get("skill") { harvestable.skill = Some(v.clone()); }
|
if let Some(v) = attrs.get("skill") { harvestable.skill = v.parse().unwrap_or(SkillType::None); }
|
||||||
if let Some(v) = attrs.get("tool") { harvestable.tool = Some(v.clone()); }
|
if let Some(v) = attrs.get("tool") { harvestable.tool = v.parse().unwrap_or(Tool::None); }
|
||||||
if let Some(v) = attrs.get("health") { harvestable.health = Some(v.clone()); }
|
if let Some(v) = attrs.get("health") {
|
||||||
if let Some(v) = attrs.get("harvesttime") { harvestable.harvesttime = v.parse().ok(); }
|
let (min, max) = parse_health_range(v);
|
||||||
if let Some(v) = attrs.get("hittime") { harvestable.hittime = v.parse().ok(); }
|
harvestable.min_health = min;
|
||||||
if let Some(v) = attrs.get("respawntime") { harvestable.respawntime = v.parse().ok(); }
|
harvestable.max_health = max;
|
||||||
|
}
|
||||||
|
if let Some(v) = attrs.get("harvesttime") { harvestable.harvesttime = v.parse().unwrap_or(0); }
|
||||||
|
if let Some(v) = attrs.get("hittime") { harvestable.hittime = v.parse().unwrap_or(0); }
|
||||||
|
if let Some(v) = attrs.get("respawntime") { harvestable.respawntime = v.parse().unwrap_or(0); }
|
||||||
|
|
||||||
// Audio (handle both cases: harvestSfx and harvestsfx)
|
// Audio (handle both cases: harvestSfx and harvestsfx)
|
||||||
if let Some(v) = attrs.get("harvestSfx").or_else(|| attrs.get("harvestsfx")) {
|
if let Some(v) = attrs.get("harvestSfx").or_else(|| attrs.get("harvestsfx")) {
|
||||||
harvestable.harvestsfx = Some(v.clone());
|
harvestable.harvestsfx = v.clone();
|
||||||
}
|
}
|
||||||
if let Some(v) = attrs.get("endSfx").or_else(|| attrs.get("endsfx")) {
|
if let Some(v) = attrs.get("endSfx").or_else(|| attrs.get("endsfx")) {
|
||||||
harvestable.endsfx = Some(v.clone());
|
harvestable.endsfx = v.clone();
|
||||||
}
|
}
|
||||||
if let Some(v) = attrs.get("receiveItemSfx").or_else(|| attrs.get("receiveitemsfx")) {
|
if let Some(v) = attrs.get("receiveItemSfx").or_else(|| attrs.get("receiveitemsfx")) {
|
||||||
harvestable.receiveitemsfx = Some(v.clone());
|
harvestable.receiveitemsfx = v.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(v) = attrs.get("animation") { harvestable.animation = Some(v.clone()); }
|
if let Some(v) = attrs.get("animation") { harvestable.animation = v.clone(); }
|
||||||
if let Some(v) = attrs.get("takehitanimation") { harvestable.takehitanimation = Some(v.clone()); }
|
if let Some(v) = attrs.get("takehitanimation") { harvestable.takehitanimation = v.clone(); }
|
||||||
if let Some(v) = attrs.get("endgfx") { harvestable.endgfx = Some(v.clone()); }
|
if let Some(v) = attrs.get("endgfx") { harvestable.endgfx = v.clone(); }
|
||||||
if let Some(v) = attrs.get("tree") { harvestable.tree = v.parse().ok(); }
|
if let Some(v) = attrs.get("tree") { harvestable.tree = v.parse().unwrap_or(0) == 1; }
|
||||||
if let Some(v) = attrs.get("hidemilestone") { harvestable.hidemilestone = v.parse().ok(); }
|
if let Some(v) = attrs.get("hidemilestone") { harvestable.hidemilestone = v.parse().unwrap_or(0) == 1; }
|
||||||
if let Some(v) = attrs.get("nohighlight") { harvestable.nohighlight = v.parse().ok(); }
|
if let Some(v) = attrs.get("nohighlight") { harvestable.nohighlight = v.parse().unwrap_or(0) == 1; }
|
||||||
|
|
||||||
// Handle both cases: hideMinimap and hideminimap
|
// Handle both cases: hideMinimap and hideminimap
|
||||||
if let Some(v) = attrs.get("hideMinimap").or_else(|| attrs.get("hideminimap")) {
|
if let Some(v) = attrs.get("hideMinimap").or_else(|| attrs.get("hideminimap")) {
|
||||||
harvestable.hideminimap = v.parse().ok();
|
harvestable.hideminimap = v.parse().unwrap_or(0) == 1;
|
||||||
}
|
}
|
||||||
if let Some(v) = attrs.get("noLeftClickInteract").or_else(|| attrs.get("noleftclickinteract")) {
|
if let Some(v) = attrs.get("noLeftClickInteract").or_else(|| attrs.get("noleftclickinteract")) {
|
||||||
harvestable.noleftclickinteract = v.parse().ok();
|
harvestable.noleftclickinteract = v.parse().unwrap_or(0) == 1;
|
||||||
}
|
}
|
||||||
if let Some(v) = attrs.get("interactDistance").or_else(|| attrs.get("interactdistance")) {
|
if let Some(v) = attrs.get("interactDistance").or_else(|| attrs.get("interactdistance")) {
|
||||||
harvestable.interactdistance = Some(v.clone());
|
harvestable.interactdistance = v.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
current_harvestable = Some(harvestable);
|
current_harvestable = Some(harvestable);
|
||||||
@@ -599,14 +618,14 @@ pub fn parse_harvestables_xml<P: AsRef<Path>>(path: P) -> Result<Vec<Harvestable
|
|||||||
if let Ok(id) = id_str.parse::<i32>() {
|
if let Ok(id) = id_str.parse::<i32>() {
|
||||||
let drop = HarvestableDrop {
|
let drop = HarvestableDrop {
|
||||||
id,
|
id,
|
||||||
minamount: attrs.get("minamount").and_then(|v| v.parse().ok()),
|
minamount: attrs.get("minamount").and_then(|v| v.parse().ok()).unwrap_or(0),
|
||||||
maxamount: attrs.get("maxamount").and_then(|v| v.parse().ok()),
|
maxamount: attrs.get("maxamount").and_then(|v| v.parse().ok()).unwrap_or(0),
|
||||||
droprate: attrs.get("droprate").and_then(|v| v.parse().ok()),
|
droprate: attrs.get("droprate").and_then(|v| v.parse().ok()).unwrap_or(0),
|
||||||
droprateboost: attrs.get("droprateboost").and_then(|v| v.parse().ok()),
|
droprateboost: attrs.get("droprateboost").and_then(|v| v.parse().ok()).unwrap_or(0),
|
||||||
amountboost: attrs.get("amountboost").and_then(|v| v.parse().ok()),
|
amountboost: attrs.get("amountboost").and_then(|v| v.parse().ok()).unwrap_or(0),
|
||||||
checks: attrs.get("checks").cloned(),
|
checks: attrs.get("checks").cloned().unwrap_or_default(),
|
||||||
comment: attrs.get("comment").cloned(),
|
comment: attrs.get("comment").cloned().unwrap_or_default(),
|
||||||
dontconsumehealth: attrs.get("dontconsumehealth").and_then(|v| v.parse().ok()),
|
dontconsumehealth: attrs.get("dontconsumehealth").and_then(|v| v.parse().ok()).unwrap_or(0) == 1,
|
||||||
};
|
};
|
||||||
harvestable.drops.push(drop);
|
harvestable.drops.push(drop);
|
||||||
}
|
}
|
||||||
|
|||||||
30
settings.json
Normal file
30
settings.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"ssh_connections": [
|
||||||
|
{
|
||||||
|
"host": "192.168.11.7",
|
||||||
|
"username": "connor",
|
||||||
|
"args": [],
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"paths": ["/home/connor/repos/cursebreaker-parser-rust"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nickname": "connor-mini"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ui_font_size": 16,
|
||||||
|
"buffer_font_size": 15,
|
||||||
|
"theme": {
|
||||||
|
"mode": "system",
|
||||||
|
"light": "One Light",
|
||||||
|
"dark": "One Dark"
|
||||||
|
},
|
||||||
|
"agent_servers": {
|
||||||
|
"Grok Build Agent": {
|
||||||
|
"type": "custom",
|
||||||
|
"command": "/usr/bin/grok",
|
||||||
|
"args": ["agent", "--yolo", "stdio"],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,9 +144,9 @@ pub fn build_world_from_documents(
|
|||||||
&mut world,
|
&mut world,
|
||||||
linking_ctx.borrow_mut().entity_map_mut(),
|
linking_ctx.borrow_mut().entity_map_mut(),
|
||||||
) {
|
) {
|
||||||
Ok(spawned) => {
|
Ok(_spawned) => {
|
||||||
info!("Spawned {} entities from prefab GUID: {}",
|
// info!("Spawned {} entities from prefab GUID: {}",
|
||||||
spawned.len(), component.prefab_ref.guid);
|
// spawned.len(), component.prefab_ref.guid);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Soft failure - warn but continue
|
// Soft failure - warn but continue
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ pub use error::{Error, Result};
|
|||||||
pub use model::{RawDocument, UnityAsset, UnityFile, UnityPrefab, UnityScene, UnityProject};
|
pub use model::{RawDocument, UnityAsset, UnityFile, UnityPrefab, UnityScene, UnityProject};
|
||||||
pub use parser::{
|
pub use parser::{
|
||||||
find_project_root, meta::MetaFile, parse_unity_file, parse_unity_file_filtered,
|
find_project_root, meta::MetaFile, parse_unity_file, parse_unity_file_filtered,
|
||||||
GuidResolver, PrefabGuidResolver,
|
parse_scene_with_project_filtered, GuidResolver, PrefabGuidResolver,
|
||||||
};
|
};
|
||||||
pub use post_processing::{compute_world_transforms, WorldTransform};
|
pub use post_processing::{compute_world_transforms, WorldTransform};
|
||||||
pub use property::PropertyValue;
|
pub use property::PropertyValue;
|
||||||
|
|||||||
@@ -317,6 +317,35 @@ impl UnityProject {
|
|||||||
crate::parser::parse_scene_with_project(&path, self)
|
crate::parser::parse_scene_with_project(&path, self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a Unity scene using the pre-built GUID resolvers with optional type filtering
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `path` - Path to the scene file (relative to project root or absolute)
|
||||||
|
/// * `type_filter` - Optional filter for Unity types and MonoBehaviour class names
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// # use unity_parser::{UnityProject, TypeFilter};
|
||||||
|
/// # let project = UnityProject::from_path("/home/user/repos/CBAssets")?;
|
||||||
|
/// let filter = TypeFilter::new(
|
||||||
|
/// vec!["GameObject", "Transform"],
|
||||||
|
/// vec!["InteractableResource"]
|
||||||
|
/// );
|
||||||
|
/// let scene = project.parse_scene_filtered("_GameAssets/Scenes/Tiles/10_3.unity", Some(&filter))?;
|
||||||
|
/// println!("Scene has {} entities", scene.entity_map.len());
|
||||||
|
/// # Ok::<(), unity_parser::Error>(())
|
||||||
|
/// ```
|
||||||
|
pub fn parse_scene_filtered(
|
||||||
|
&self,
|
||||||
|
path: impl AsRef<Path>,
|
||||||
|
type_filter: Option<&crate::types::TypeFilter>
|
||||||
|
) -> crate::Result<UnityScene> {
|
||||||
|
let path = self.resolve_path(path.as_ref());
|
||||||
|
crate::parser::parse_scene_with_project_filtered(&path, self, type_filter)
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse a Unity prefab using the pre-built GUID resolvers
|
/// Parse a Unity prefab using the pre-built GUID resolvers
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
|
|||||||
@@ -208,14 +208,29 @@ fn parse_scene(path: &Path, content: &str, type_filter: Option<&TypeFilter>) ->
|
|||||||
/// * `path` - Path to the scene file
|
/// * `path` - Path to the scene file
|
||||||
/// * `project` - Pre-initialized UnityProject with GUID resolvers
|
/// * `project` - Pre-initialized UnityProject with GUID resolvers
|
||||||
pub fn parse_scene_with_project(path: &Path, project: &UnityProject) -> Result<UnityScene> {
|
pub fn parse_scene_with_project(path: &Path, project: &UnityProject) -> Result<UnityScene> {
|
||||||
|
parse_scene_with_project_filtered(path, project, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a scene file using pre-built GUID resolvers from a UnityProject with optional type filtering
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `path` - Path to the scene file
|
||||||
|
/// * `project` - Pre-initialized UnityProject with GUID resolvers
|
||||||
|
/// * `type_filter` - Optional filter for Unity types and MonoBehaviour class names
|
||||||
|
pub fn parse_scene_with_project_filtered(
|
||||||
|
path: &Path,
|
||||||
|
project: &UnityProject,
|
||||||
|
type_filter: Option<&TypeFilter>
|
||||||
|
) -> Result<UnityScene> {
|
||||||
// Read the file
|
// Read the file
|
||||||
let content = std::fs::read_to_string(path)?;
|
let content = std::fs::read_to_string(path)?;
|
||||||
|
|
||||||
// Validate Unity header
|
// Validate Unity header
|
||||||
validate_unity_header(&content, path)?;
|
validate_unity_header(&content, path)?;
|
||||||
|
|
||||||
// Parse raw documents
|
// Parse raw documents with type filtering
|
||||||
let raw_documents = parse_raw_documents(&content, None)?;
|
let raw_documents = parse_raw_documents(&content, type_filter)?;
|
||||||
|
|
||||||
// Build ECS world from documents using project's resolvers
|
// Build ECS world from documents using project's resolvers
|
||||||
let (world, entity_map) = crate::ecs::build_world_from_documents(
|
let (world, entity_map) = crate::ecs::build_world_from_documents(
|
||||||
|
|||||||
Reference in New Issue
Block a user