时隔两年,再次接着写
为公共事业做贡献,做了个开源版本:scratch.lite
开源版本带MySQL后台服务器,功能:注册、登录、保存作品、分享、修改作品名称、保存作品缩略图。
有兴趣的朋友可以去下载参考:lite: 一个轻量级的Scratch编程分享平台:注册登录、作品创作、作品管理、素材管理、用户管理,作品点赞、收藏、分享。
Scratch二次开发的纯技术交流QQ群:115224892/914159821
(有搭建手册)
这次我们来聊聊Scratch的素材管理定制方面的内容(扩展管理后续再放上来吧)
整个Scratch作品,基本上就是围绕背景、角色、造型、声音这4类素材来操作的。
但官方开源时,已直接把这几类素材直接放在Scratch中处理了。
要求不高的话,这也无所谓。
如果能放在后台去管理,大体上会有三个好处:
1、减小lib.min.js文件的大小;
2、可以灵活的管理各类素材(各类节假日等时机,可以放上应景的素材);
3、每次打开素材选择窗口时,就不必要一次性加载了(默认的是会从服务器上下载全部素材的。举例:当打开背景选择窗口时,会直接把全部的背景图片都下载下来,严重浪费带宽,如果网络慢点的话,同时也会影响体验)。
指定默认作品时,也能做些应景的事,或是每机构都可以有自己的默认作品,这样用户一打开或是新建作品时,就直接使用默认作品模板了(开源版本中,已实现了这个功能,后续也可以聊一聊)。
1、Scratch中的源代码;
2、Scratch中的接口;
3、服务器端的源代码。
a.背景选择窗口的入口文件:/src/containers/backdrop-library.jsx
此文件仅仅只是一个入口,它会在此准备好背景的分类、背景数据。
b.它后面会直接调用/src/components/library/library.jsx来具体执行(这个文件官方原版本:教程、造型、背景、角色、声音、扩展等界面共用。此文件耦合太紧了,很是让人头疼的)。
c.library.jsx会实现一个Modal,并会使用/src/containers/library-item.jsx显示具体的背景(这个Modal及LibraryItem的属性就乱的很(狠)啊,毕竟是好几个不同的东西共用的。)
本人已把各娄素材选择窗口,已从上面的共用文件中分离出来了(舒服... ...)
在贴代码前,说说都做了些什么:
1、直接从服务器获取背景的分类数据;
2、直接从服务器上获取背景数据(采用了流式加载:第一次只加载30来个,后续当用户鼠标滚动到底部时,会再次加载30来个,直到加载完全部的背景);
3、优化了原搜索功能:可以在不同分类下搜索;
4、直接把原backdrop-library.jsx、library.jsx、LibraryItem直接在一次性搞定了(虽然少了些共用性及同代码片段的复用,但确实简洁、舒服多了)。
上代码,上完整的源代码,上香香的源代码:
- // 背景选择窗口
- import bindAll from 'lodash.bindall';
- import PropTypes from 'prop-types';
- import React from 'react';
- import VM from 'scratch-vm';
- import classNames from 'classnames';
- import Filter from '../components/filter/filter.jsx';
- import Divider from '../components/divider/divider.jsx';
- import TagButton from './tag-button.jsx';
- import Spinner from '../components/spinner/spinner.jsx';
- import {Card} from 'antd';
- const {Meta} = Card;
-
- import Modal from '../containers/modal.jsx'; // 选择窗口组件
- import {getBackdropLibrary} from '../session-api';
- import styles from './project-library.css';
-
- class BackdropLibrary extends React.Component {
- constructor (props) {
- super(props);
- bindAll(this, [
- 'handleClose',
- 'handleFilterChange',
- 'handleFilterKeyUp',
- 'handleFilterClear',
- 'handleTagClick',
- 'handleOnScroll',
- 'handleItemSelect',
- 'setFilteredDataRef',
- 'handleGetMoreItem'
- ]);
- this.state = {
- isSearch: false, // 开始搜索
- isEnterSearched: false, // 是否已经通过回车搜索过:在未回车搜索过时,直接清空搜索框时,不必去取分类的全部
- filterQuery: '',
- selectedTag: 0,
- tagList: [], // 分类标签
- itemList: [], // itemList.length 已获取的背景数,用于流方式加载
- isOver: false, // 是否已获取了分类下的全部背景
- loading: true // 是否已加载完
- };
- }
- componentWillMount (){
- this.getItemList(true, 1); // 加载完后,第一次获取背景列表
-
- }
- componentDidUpdate (prevProps, prevState) {
- if (prevState.selectedTag !== this.state.selectedTag || this.state.isSearch) {
- this.getItemList(true, 0);
- }
- }
- // 获取背景列表:newList:新列表, newTag:1:同时获取分类数据
- getItemList (newList, newTag) {
- // console.info("当前状态值:", this.state);
- if (newList) { // 只有滚动到底时,才不清空已获取的背景列表
- this.setState({itemList: [], isOver: false});
- }
-
- if (this.state.isSearch) {
- this.setState({isSearch: false});
- }
-
- if (this.state.isOver) {
- return;
- }
-
- // 组件获取条件 tag:是否获取分类; f: 搜索字符串; t: 分类; l: 已经获取的背景数; n: 每次获取的背景数,默认为32个
- const searchParam = `tag=${newTag}&&f=${this.state.filterQuery}&&t=${this.state.selectedTag}&&l=${this.state.itemList.length}&n=32`;
- getBackdropLibrary(searchParam).then(res => {
- // console.error("getBackdropLibrary", res);
- this.setState({loading: false});
-
- if (1 == newTag) {
- this.setState({tagList: res.tags});
- }
-
- if (res.data.length < 32) {
- this.setState({isOver: true});
- }
-
- if (newList){
- this.setState({itemList: res.data});
- } else {
- this.setState({itemList: this.state.itemList.concat(res.data)});
- }
- });
- }
-
-
- // 关闭窗口
- handleClose () {
- this.props.onRequestClose();
- }
-
- // 切换标签
- handleTagClick (id) {
- if (this.state.selectedTag != id){
- this.setState({filterQuery: '', isEnterSearched: false, selectedTag: id, itemList: [], isOver: false, loading: true});
- }
- }
-
- // 搜索:输入字符串
- handleFilterChange (event) {
- this.setState({filterQuery: event.target.value});
- }
- // 搜索:按回车时开始搜索
- handleFilterKeyUp (event) {
- if (event.keyCode === 13 && event.target.value.length > 0) {
- this.setState({isSearch: true, isEnterSearched: true, itemList: [], isOver: false, loading: true}); // 触发搜索
- }
- }
- // 搜索:清空字符串
- handleFilterClear () {
- this.setState({filterQuery: ''});
- if (this.state.isEnterSearched) { // 在未回车搜索过时,直接清空搜索框时,不必去取分类的全部
- this.setState({isSearch: true, isEnterSearched: false, itemList: [], isOver: false, loading: true});
- }
- }
-
- // 绑定组件变量,为监听页面滚动
- setFilteredDataRef (ref) {
- this.filteredDataRef = ref;
- }
- // 监听页面滚动,到底后,再次自动加载背景
- handleOnScroll () {
- if (this.filteredDataRef) {
- const contentScrollTop = this.filteredDataRef.scrollTop; // 滚动条距离顶部
- const clientHeight = this.filteredDataRef.clientHeight; // 可视区域
- const scrollHeight = this.filteredDataRef.scrollHeight; // 滚动条内容的总高度
- if (contentScrollTop + clientHeight >= scrollHeight - 10) { // 提前 10 个位置开始获取后续元素
- if (!this.state.isOver) {
- this.getItemList(false, 0); // 继续获取数据的方法
- }
- // console.error('已到底');
- }
- }
- }
- // 手动点击按钮,继续获取数据的方法
- handleGetMoreItem () {
- if (!this.state.isOver) {
- this.getItemList(false, 0);
- }
- }
-
- // 打开背景
- handleItemSelect (index) {
- this.handleClose();
- const item = this.state.itemList[index];
-
- const vmBackdrop = {
- name: item.name,
- rotationCenterX: item.info0,
- rotationCenterY: item.info1,
- bitmapResolution: item.info2,
- skinId: null
- };
-
- // Do not switch to stage, just add the backdrop
- this.props.vm.addBackdrop(item.md5, vmBackdrop);
- }
-
- render () {
- return (
- <Modal
- fullScreen
- contentLabel="选择一个背景"
- id="backdropLibrary"
- onRequestClose={this.handleClose}
- >
-
- {/* 搜索、分类部分 */}
- <div className={styles.filterBar}>
- <Filter
- className={classNames(styles.filterBarItem, styles.filter)}
- filterQuery={this.state.filterQuery}
- inputClassName={styles.filterInput}
- placeholderText="搜索"
- onChange={this.handleFilterChange}
- onClear={this.handleFilterClear}
- onKeyUp={this.handleFilterKeyUp}
- />
-
- <Divider className={classNames(styles.filterBarItem, styles.divider)} />
-
- <div className={styles.tagWrapper}>
- {[{tag: "全部", id: 0}].concat(this.state.tagList).map((tag, index) => (
- <TagButton
- active={this.state.selectedTag === tag.id}
- className={classNames(styles.filterBarItem, styles.tagButton)}
- key={`tag-button-${index}`}
- onClick={()=>this.handleTagClick(tag.id)}
- tag={tag.tag}
- />
- ))}
- </div>
- </div>
-
- <div
- className={classNames(styles.libraryScrollGrid, styles.withFilterBar)}
- ref={this.setFilteredDataRef}
- onScrollCapture={this.handleOnScroll}
- >
- {this.state.loading ? (
- <div className={styles.spinnerWrapper}>
- <Spinner
- large
- level="primary"
- />
- </div>
- ) : (<>
- {(this.state.itemList.length == 0)&&(
- <div className={styles.spinnerWrapper}>
- <div className={styles.spanBox}>空</div>
- </div>
- )}
-
- {this.state.itemList.map((item, index) => (
- <Card
- hoverable
- className={styles.ItemCard}
- style={{width: 180, margin: 6, borderRadius: 6}}
- cover={<img src={`/scratch/assets/${item.md5}`} style={{width: 160, height: 120, margin: 10}}/>}
- key={index}
- onClick={()=>this.handleItemSelect(index)}
- >
- <Meta title={item.name} />
- </Card>
- ))}
-
- {!this.state.isOver && (
- <div
- className={styles.spinnerWrapper1}
- onClick={this.handleGetMoreItem}
- >
- <div className={styles.spanBox}>点我可继续</div>
- </div>
- )}
- </>)}
- </div>
- </Modal>
- );
- }
- }
-
- BackdropLibrary.propTypes = {
- onRequestClose: PropTypes.func,
- vm: PropTypes.instanceOf(VM).isRequired
- };
-
- export default BackdropLibrary;
哦哦哦,本人技术男,总觉得自己做的CSS不好看,所以,这次引入了阿里的Ant Design。
(具体怎么引入Ant Design到Scratch,那又是另一个小话题了)。
上面只是选择背景的窗口,还有一个地方要注意:Scratch可以直接获取一个随机背景的,此功能有两处,所在的文件分别是:/src/containers/costume-tab.jsx与/src/containers/stage-selector.jsx,
此处源代码为:
- // 下面源代码所在的文件:/src/containers/costume-tab.jsx
-
- // 先引入接口
- import {getRandomBackdrop, getRandomCostume} from '../session-api';
- // 从上面也引入了getRandomCostume可以看出,此文件也同时实现的随机获取一个造型的功能
-
- ... ...
-
- handleSurpriseBackdrop () {
- // 随机选择一个背景
- // 这里要特别注意,要彻底明白背景的各个属性数据的意义
- // 官方的是日积月累搞出来的,为了兼容,做了很多“坏”事
- getRandomBackdrop().then(res => {
- if (res.status == "ok"){
- const vmBackdrop = {
- name: res.data.name,
- md5: res.data.md5,
- rotationCenterX: res.data.info0,
- rotationCenterY: res.data.info1,
- bitmapResolution: res.data.info2,
- skinId: null
- };
- this.props.vm.addBackdrop(res.data.md5, vmBackdrop);
- }
- });
-
- // const item = backdropLibraryContent[Math.floor(Math.random() * backdropLibraryContent.length)];
- // const vmCostume = {
- // name: item.name,
- // md5: item.md5,
- // rotationCenterX: item.info[0] && item.info[0] / 2,
- // rotationCenterY: item.info[1] && item.info[1] / 2,
- // bitmapResolution: item.info.length > 2 ? item.info[2] : 1,
- // skinId: null
- // };
- // this.handleNewCostume(vmCostume);
- }
- // 源代码所在的文件:/src/containers/stage-selector.jsx
-
- // 引入接口API
- import {getRandomBackdrop} from '../session-api';
-
- ... ...
-
- handleSurpriseBackdrop (e) {
- e.stopPropagation(); // Prevent click from falling through to selecting stage.
-
- getRandomBackdrop().then(res => {
- if (res.status == "ok"){
- const vmBackdrop = {
- name: res.data.name,
- md5: res.data.md5,
- rotationCenterX: res.data.info0,
- rotationCenterY: res.data.info1,
- bitmapResolution: res.data.info2,
- skinId: null
- };
- this.props.vm.addBackdrop(res.data.md5, vmBackdrop);
- }
- });
- // const item = backdropLibraryContent[Math.floor(Math.random() * backdropLibraryContent.length)];
- // this.addBackdropFromLibraryItem(item, false);
- }
主要是二个接口:
1、getBackdropLibrary:获取背景数据接口(第一次获取时,会同时取回背景的分类的数据);
2、getRandomBackdrop:随机获取一个背景接口。
上代码,上完整的源代码,上香香的源代码:
(本人直接把一些Scratch与服务器对接的API都统一放在了一个文件中,这次一并多贴点吧。现在我们只看看 getBackdropLibrary、getRandomBackdrop就好)
- // 登录一:首页打开Scratch时,自动获取一次用户登录信息
- module.exports.requestSession = (resolve, reject) => (
- miniFetch(resolve, reject, '/user/getSession')
- );
-
- // 登录二:提交账号、密码进行登录
- module.exports.requestLogin = (data) => new Promise((resolve, reject) => {
- miniFetch(resolve, reject, '/user/login', {body:data})
- });
-
- // 退出:提交账号,退出登录状态
- module.exports.requestLogout = (resolve, reject, data) => (
- miniFetch(resolve, reject, '/user/logout', {body: data})
- );
-
- // 获取项目源代码
- module.exports.requestProject = (resolve, reject, projectId) => (
- miniFetch(resolve, reject, `/scratch/project/${projectId}`)
- );
-
- // 保存标题
- module.exports.requestSaveProjectTitle = (resolve, reject, projectId, projectTitle) => {
- miniFetch(resolve, reject, '/scratch/saveProjcetTitle', {body:`id=${projectId}&title=${projectTitle}`})
- };
-
- // 保存缩略图
- module.exports.requestSaveProjectThumbnail = (resolve, reject, projectId, thumbnailBlob) => {
- miniFetch(resolve, reject, `/scratch/thumbnail/${projectId}`, {body:thumbnailBlob, headers:{'Content-Type': 'image/png'}})
- };
-
- // 分享作品 或 取消分享
- module.exports.requestShareProject = (projectId, s) => new Promise((resolve, reject) => {
- miniFetch(resolve, reject, `/scratch/shareProject/${projectId}`, {body:`s=${s}`});
- });
-
- // 获取课程卡数据
- module.exports.requestCousreCard = (resolve, reject, lessonId) => {
- miniFetch(resolve, reject, `/course/getcard/${lessonId}`);
- };
-
-
- // 获取我的作品
- module.exports.getMyProjectLibrary = (data) => new Promise((resolve, reject) => {
- miniFetch(resolve, reject, '/scratch/getMyProjectLibrary', {body: data});
- });
-
- // 获取优秀作品
- module.exports.getYxProjectLibrary = (data) => new Promise((resolve, reject) => {
- miniFetch(resolve, reject, '/scratch/getYxProjectLibrary', {body: data});
- });
-
- // 获取背景
- module.exports.getBackdropLibrary = (data) => new Promise((resolve, reject) => {
- miniFetch(resolve, reject, '/scratch/getBackdropLibrary', {body: data});
- });
- // 随机获取背景
- module.exports.getRandomBackdrop = () => new Promise((resolve, reject) => {
- miniFetch(resolve, reject, '/scratch/getRandomBackdrop');
- });
-
- // 获取造型
- module.exports.getCostumeLibrary = (data) => new Promise((resolve, reject) => {
- miniFetch(resolve, reject, '/scratch/getCostumeLibrary', {body: data});
- });
- // 随机获取造型
- module.exports.getRandomCostume = () => new Promise((resolve, reject) => {
- miniFetch(resolve, reject, '/scratch/getRandomCostume');
- });
-
- // 获取声音
- module.exports.getSoundLibrary = (data) => new Promise((resolve, reject) => {
- miniFetch(resolve, reject, '/scratch/getSoundLibrary', {body: data});
- });
- // 随机获取声音
- module.exports.getRandomSound = () => new Promise((resolve, reject) => {
- miniFetch(resolve, reject, '/scratch/getRandomSound');
- });
-
- // 获取角色
- module.exports.getSpriteLibrary = (data) => new Promise((resolve, reject) => {
- miniFetch(resolve, reject, '/scratch/getSpriteLibrary', {body: data});
- });
- // 随机获取角色
- module.exports.getRandomSprite = () => new Promise((resolve, reject) => {
- miniFetch(resolve, reject, '/scratch/getRandomSprite');
- });
-
-
- // 公用函数
- function miniFetch(resolve, reject, uri, params){
- // uri = "https://comecode.net"+uri;
-
- var opts = {
- headers:{
- 'Accept':'application/json,text/plain,*/*',/* 格式限制:json、文本、其他格式 */
- 'Content-Type':'application/x-www-form-urlencoded'/* 请求内容类型 */
- },
- method:'post'
- }
- if (params){
- if (params.headers) {opts['headers'] = Object.assign(opts['headers'], params.headers)}
- if (params.method) {opts["method"] = params.method}
- if (params.body) {opts["body"] = params.body}
- }
-
- fetch(uri, opts).then(response=>{
- var body = response.json();
- if(response.status == 200){
- return resolve(body);
- }
- return reject(body)
- })
- .catch(err=>reject(err))
- };
此处不用多说,一看便知。
这块比较重要,也比较简单(注:开源的是NodeJS + Express实现的,如果要用其他服务器端(JAVA、PHP、Go... ...)可以参数一二):
- // 获取背景
- // 组件获取条件 tag:是否获取分类; f: 搜索字符串; t: 分类; l: 已经获取的背景数; n: 每次获取的背景数,默认为20个
- router.post('/getBackdropLibrary', function (req, res) {
- var WHERE = '';
- if (req.body.t != 0){
- WHERE = ' AND tagId='+req.body.t;
- }
-
- if (req.body.f && req.body.f!=''){
- WHERE += ` AND name LIKE '%${req.body.f}%'`;
- }
-
- var SELECT =`SELECT id, name, md5, info0, info1, info2 FROM material_backdrop WHERE state=1 ${WHERE} ORDER BY name DESC LIMIT ${req.body.l},${req.body.n}`;
- DB.query(SELECT, function(err, Backdrop){
- if (err) {
- res.status(200).send({status:"err", data: [], tags: []});
- return;
- }
-
- if (req.body.tag == 0) {
- res.status(200).send({status:"ok", data: Backdrop, tags: []});
- return;
- }
-
- // 取一次背景分类
- SELECT =`SELECT id, tag FROM material_tags WHERE type=1 ORDER BY tag DESC`;
- DB.query(SELECT, function(err, tags){
- if (err) {
- res.status(200).send({status:"err", data: [], tags: []});
- return;
- }
-
- res.status(200).send({status:"ok", data: Backdrop, tags: tags});
- })
- })
- });
-
- // 随机获取一个背景
- router.post('/getRandomBackdrop', function (req, res) {
- const SELECT = `SELECT name, md5, info0, info1, info2 FROM material_backdrop` +
- ` JOIN (SELECT MAX(id) AS maxId, MIN(id) AS minId FROM material_backdrop WHERE state=1) AS m ` +
- ` WHERE id >= ROUND(RAND()*(m.maxId - m.minId) + m.minId) AND state=1 LIMIT 1`;
- DB.query(SELECT, function(err, B){
- if (err || B.length < 1) {
- res.status(200).send({status:"err", data: {}});
- return;
- }
-
- res.status(200).send({status:"ok", data: B[0]});
- })
- });
- -- ----------------------------
- -- Table structure for material_backdrop
- -- ----------------------------
- DROP TABLE IF EXISTS `material_backdrop`;
- CREATE TABLE `material_backdrop` (
- `id` int unsigned NOT NULL AUTO_INCREMENT,
- `tagId` int unsigned NOT NULL COMMENT '分类ID',
- `name` char(32) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT '新背景',
- `md5` char(64) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL,
- `info0` int DEFAULT '960' COMMENT '图片宽',
- `info1` int DEFAULT '720' COMMENT '图片高',
- `info2` int DEFAULT '2',
- `state` tinyint DEFAULT '1' COMMENT '状态:0停用,1正常',
- PRIMARY KEY (`id`,`tagId`)
- ) ENGINE=InnoDB AUTO_INCREMENT=89 DEFAULT CHARSET=utf8;
关于在服务器上怎么去增、删、改背景数据,则会后端不同而不同,大体上就是对上表的操作。
主要是要明白各属性的意义,就可以灵活的操作了。
说了这么多,也只大体上把Scratch背景怎么实现后台管理说了个大概,实在是篇幅有限,如有需要交流的,可加上面的群,一起交流。
Scratch素材,除了背景,还有造型、声音、角色,(角色其实就是由N个造型+N个声音组合而成的,Scratch中的角色数据文件sprite.json实在是太累赘了,后面有空可以贴点这方面的数据结构分析方面的内容,这样就可以知道哪些数据有用了,Scratch的历史积累,有很多数据段,是完全没有意义也没用到的)。
后续再慢慢把造型、声音、角色这方面的后台管理功能也放上来吧,大体上与背景管理差不多。
想提前了解的,下载开源版本,就都有了。。。 。。。
写在后面:
如果本文章对您有帮助,请不吝点个赞再走(点赞不要钱,只管拼命赞)!!!
您的支持,就是本人继续分享的源动力,后续内容更加硬核+精彩,请 收藏+关注 ,方便您及时看到更新的内容!!!
Bailee 了个Bye!!!