时隔两年,再次接着写
为公共事业做贡献,做了个开源版本:scratch.lite
开源版本带MySQL后台服务器,功能:注册、登录、保存作品、分享、修改作品名称、保存作品缩略图。
有兴趣的朋友可以去下载参考:lite: 一个轻量级的Scratch编程分享平台:注册登录、作品创作、作品管理、素材管理、用户管理,作品点赞、收藏、分享。
Scratch二次开发的纯技术交流QQ群:115224892/914159821
(有搭建手册)
这次我们来聊聊Scratch的素材管理定制方面的内容(扩展管理后续再放上来吧)
整个Scratch作品,基本上就是围绕背景、角色、造型、声音这4类素材来操作的。
但官方开源时,已直接把这几类素材直接放在Scratch中处理了。
要求不高的话,这也无所谓。
如果能放在后台去管理,大体上会有三个好处:
1、减小lib.min.js文件的大小;
2、可以灵活的管理各类素材(各类节假日等时机,可以放上应景的素材);
3、每次打开素材选择窗口时,就不必要一次性加载了(默认的是会从服务器上下载全部素材的。举例:当打开背景选择窗口时,会直接把全部的背景图片都下载下来,严重浪费带宽,如果网络慢点的话,同时也会影响体验)。
指定默认作品时,也能做些应景的事,或是每机构都可以有自己的默认作品,这样用户一打开或是新建作品时,就直接使用默认作品模板了(开源版本中,已实现了这个功能,后续也可以聊一聊)。
正题:Scratch背景 后台管理功能的实现
下面将从三个方面来说:
1、Scratch中的源代码;
2、Scratch中的接口;
3、服务器端的源代码。
-
一、Scratch中的关键源代码
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);
}
-
二、Scratch与服务器对接的接口源代码
主要是二个接口:
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]});
})
});
-
重要:数据库结构(仅提供MySQL版本):
-- ----------------------------
-- 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!!!