1use std::cmp::max;
2use std::fs;
3use std::fs::File;
4use std::io::{Read, Write};
5use std::path::{Path, PathBuf, MAIN_SEPARATOR};
6
7use cfg_if::cfg_if;
8use directories::UserDirs;
9use zip::write::SimpleFileOptions;
10use zip::{ZipArchive, ZipWriter};
11
12use crate::image::{Color, Image, Rgb24};
13use crate::pyxel::Pyxel;
14use crate::resource_data::{ResourceData1, ResourceData2};
15use crate::screencast::Screencast;
16use crate::settings::{
17 BASE_DIR, DEFAULT_CAPTURE_SCALE, DEFAULT_CAPTURE_SEC, PALETTE_FILE_EXTENSION,
18 RESOURCE_ARCHIVE_NAME, RESOURCE_FORMAT_VERSION,
19};
20
21pub struct Resource {
22 capture_scale: u32,
23 screencast: Screencast,
24}
25
26impl Resource {
27 pub fn new(capture_scale: Option<u32>, capture_sec: Option<u32>, fps: u32) -> Self {
28 let capture_scale = capture_scale.unwrap_or(DEFAULT_CAPTURE_SCALE);
29 let capture_sec = capture_sec.unwrap_or(DEFAULT_CAPTURE_SEC);
30
31 Self {
32 capture_scale: max(capture_scale, 1),
33 screencast: Screencast::new(fps, capture_sec),
34 }
35 }
36}
37
38impl Pyxel {
39 pub fn load(
40 &mut self,
41 filename: &str,
42 exclude_images: Option<bool>,
43 exclude_tilemaps: Option<bool>,
44 exclude_sounds: Option<bool>,
45 exclude_musics: Option<bool>,
46 include_colors: Option<bool>,
47 include_channels: Option<bool>,
48 include_tones: Option<bool>,
49 ) {
50 let mut archive = ZipArchive::new(
51 File::open(Path::new(&filename))
52 .unwrap_or_else(|_| panic!("Failed to open file '{filename}'")),
53 )
54 .unwrap();
55
56 if archive.by_name("pyxel_resource/version").is_ok() {
58 println!("An old Pyxel resource file '{filename}' is loaded. Please re-save it with the latest Pyxel.");
59 self.load_old_resource(
60 &mut archive,
61 filename,
62 !exclude_images.unwrap_or(false),
63 !exclude_tilemaps.unwrap_or(false),
64 !exclude_sounds.unwrap_or(false),
65 !exclude_musics.unwrap_or(false),
66 );
67 self.load_pyxel_palette_file(filename);
68 return;
69 }
70
71 let mut file = archive.by_name(RESOURCE_ARCHIVE_NAME).unwrap();
73 let mut toml_text = String::new();
74 file.read_to_string(&mut toml_text).unwrap();
75 let format_version = Self::parse_format_version(&toml_text);
76 assert!(
77 format_version <= RESOURCE_FORMAT_VERSION,
78 "Unknown resource file version"
79 );
80
81 if format_version >= 2 {
82 let resource_data = ResourceData2::from_toml(&toml_text);
83 resource_data.to_runtime(
84 self,
85 exclude_images.unwrap_or(false),
86 exclude_tilemaps.unwrap_or(false),
87 exclude_sounds.unwrap_or(false),
88 exclude_musics.unwrap_or(false),
89 include_colors.unwrap_or(false),
90 include_channels.unwrap_or(false),
91 include_tones.unwrap_or(false),
92 );
93 self.load_pyxel_palette_file(filename);
94 } else {
95 let resource_data = ResourceData1::from_toml(&toml_text);
96 resource_data.to_runtime(
97 self,
98 exclude_images.unwrap_or(false),
99 exclude_tilemaps.unwrap_or(false),
100 exclude_sounds.unwrap_or(false),
101 exclude_musics.unwrap_or(false),
102 include_colors.unwrap_or(false),
103 include_channels.unwrap_or(false),
104 include_tones.unwrap_or(false),
105 );
106 self.load_pyxel_palette_file(filename);
107 }
108 }
109
110 pub fn save(
111 &mut self,
112 filename: &str,
113 exclude_images: Option<bool>,
114 exclude_tilemaps: Option<bool>,
115 exclude_sounds: Option<bool>,
116 exclude_musics: Option<bool>,
117 include_colors: Option<bool>,
118 include_channels: Option<bool>,
119 include_tones: Option<bool>,
120 ) {
121 let toml_text = ResourceData2::from_runtime(self).to_toml(
122 exclude_images.unwrap_or(false),
123 exclude_tilemaps.unwrap_or(false),
124 exclude_sounds.unwrap_or(false),
125 exclude_musics.unwrap_or(false),
126 include_colors.unwrap_or(false),
127 include_channels.unwrap_or(false),
128 include_tones.unwrap_or(false),
129 );
130
131 let path = std::path::Path::new(&filename);
132 let file = std::fs::File::create(path)
133 .unwrap_or_else(|_| panic!("Failed to open file '{filename}'"));
134 let mut zip = ZipWriter::new(file);
135 zip.start_file(RESOURCE_ARCHIVE_NAME, SimpleFileOptions::default())
136 .unwrap();
137 zip.write_all(toml_text.as_bytes()).unwrap();
138 zip.finish().unwrap();
139
140 #[cfg(target_os = "emscripten")]
141 pyxel_platform::emscripten::save_file(filename);
142 }
143
144 pub fn screenshot(&mut self, scale: Option<u32>) {
145 let filename = Self::prepend_desktop_path(&format!("pyxel-{}", Self::datetime_string()));
146 let scale = max(scale.unwrap_or(self.resource.capture_scale), 1);
147 self.screen.lock().save(&filename, scale);
148
149 #[cfg(target_os = "emscripten")]
150 pyxel_platform::emscripten::save_file(&(filename + ".png"));
151 }
152
153 pub fn screencast(&mut self, scale: Option<u32>) {
154 let filename = Self::prepend_desktop_path(&format!("pyxel-{}", Self::datetime_string()));
155 let scale = max(scale.unwrap_or(self.resource.capture_scale), 1);
156 self.resource.screencast.save(&filename, scale);
157
158 #[cfg(target_os = "emscripten")]
159 pyxel_platform::emscripten::save_file(&(filename + ".gif"));
160 }
161
162 pub fn reset_screencast(&mut self) {
163 self.resource.screencast.reset();
164 }
165
166 pub fn user_data_dir(&self, vendor_name: &str, app_name: &str) -> String {
167 let home_dir = UserDirs::new()
168 .map_or_else(PathBuf::new, |user_dirs| user_dirs.home_dir().to_path_buf());
169 let app_data_dir = home_dir
170 .join(BASE_DIR)
171 .join(Self::make_dir_name(vendor_name))
172 .join(Self::make_dir_name(app_name));
173
174 if !app_data_dir.exists() {
175 fs::create_dir_all(&app_data_dir).unwrap();
176 println!("created '{}'", app_data_dir.to_string_lossy());
177 }
178
179 let mut app_data_dir = app_data_dir.to_string_lossy().to_string();
180 if !app_data_dir.ends_with(MAIN_SEPARATOR) {
181 app_data_dir.push(MAIN_SEPARATOR);
182 }
183
184 app_data_dir
185 }
186
187 pub(crate) fn capture_screen(&mut self) {
188 self.resource.screencast.capture(
189 self.width,
190 self.height,
191 &self.screen.lock().canvas.data,
192 &self.colors.lock(),
193 self.frame_count,
194 );
195 }
196
197 pub(crate) fn dump_image_bank(&self, image_index: u32) {
198 let filename = Self::prepend_desktop_path(&format!("pyxel-image{image_index}"));
199
200 if let Some(image) = self.images.lock().get(image_index as usize) {
201 image.lock().save(&filename, 1);
202
203 #[cfg(target_os = "emscripten")]
204 pyxel_platform::emscripten::save_file(&(filename + ".png"));
205 }
206 }
207
208 pub(crate) fn dump_palette(&self) {
209 let filename = Self::prepend_desktop_path("pyxel-palette");
210 let num_colors = self.colors.lock().len();
211 let image = Image::new(num_colors as u32, 1);
212
213 {
214 let mut image = image.lock();
215 for i in 0..num_colors {
216 image.pset(i as f64, 0.0, i as Color);
217 }
218
219 image.save(&filename, 16);
220
221 #[cfg(target_os = "emscripten")]
222 pyxel_platform::emscripten::save_file(&(filename + ".png"));
223 }
224 }
225
226 fn datetime_string() -> String {
227 cfg_if! {
228 if #[cfg(target_os = "emscripten")] {
229 pyxel_platform::emscripten::datetime_string()
230 } else {
231 chrono::Local::now().format("%Y%m%d-%H%M%S").to_string()
232 }
233 }
234 }
235
236 fn prepend_desktop_path(basename: &str) -> String {
237 let desktop_dir = UserDirs::new()
238 .and_then(|user_dirs| user_dirs.desktop_dir().map(Path::to_path_buf))
239 .unwrap_or_default();
240
241 desktop_dir.join(basename).to_string_lossy().to_string()
242 }
243
244 fn parse_format_version(toml_text: &str) -> u32 {
245 toml_text
246 .lines()
247 .find(|line| line.trim().starts_with("format_version"))
248 .and_then(|line| line.split_once('='))
249 .map(|(_, value)| value.trim().parse::<u32>())
250 .unwrap()
251 .unwrap()
252 }
253
254 fn load_pyxel_palette_file(&mut self, filename: &str) {
255 let filename = filename
256 .rfind('.')
257 .map_or(filename, |i| &filename[..i])
258 .to_string()
259 + PALETTE_FILE_EXTENSION;
260
261 if let Ok(mut file) = File::open(Path::new(&filename)) {
262 let mut contents = String::new();
263 file.read_to_string(&mut contents).unwrap();
264
265 *self.colors.lock() = contents
266 .replace("\r\n", "\n")
267 .replace('\r', "\n")
268 .split('\n')
269 .filter(|s| !s.is_empty())
270 .map(|s| u32::from_str_radix(s.trim(), 16).unwrap() as Rgb24)
271 .collect();
272 }
273 }
274
275 fn make_dir_name(name: &str) -> String {
276 name.to_lowercase()
277 .replace(' ', "_")
278 .chars()
279 .filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
280 .collect()
281 }
282}