pyxel/
resource.rs

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        // Old resource file
57        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        // New resource file
72        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}